diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 85af970e1..fbe65f86b 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -595,7 +595,7 @@ class GoTrueClient { if (token != null) 'token': token, 'type': type.snakeCase, 'redirect_to': redirectTo, - 'gotrue_meta_security': {'captchaToken': captchaToken}, + 'gotrue_meta_security': {'captcha_token': captchaToken}, if (tokenHash != null) 'token_hash': tokenHash, }; final fetchOptions = GotrueRequestOptions(headers: _headers, body: body); @@ -765,7 +765,7 @@ class GoTrueClient { options: options, ); - if ((response as Map).containsKey(['message_id'])) { + if ((response as Map).containsKey('message_id')) { return ResendResponse(messageId: response['message_id']); } return ResendResponse(); diff --git a/packages/gotrue/test/otp_mock_test.dart b/packages/gotrue/test/otp_mock_test.dart index 41f284636..c27bc1c50 100644 --- a/packages/gotrue/test/otp_mock_test.dart +++ b/packages/gotrue/test/otp_mock_test.dart @@ -244,6 +244,15 @@ void main() { expect(response, isA()); }); + test('resend() parses the message id from the response', () async { + final response = await client.resend( + phone: testPhone, + type: OtpType.sms, + ); + + expect(response.messageId, 'mock-message-id-resend'); + }); + test('resend() with email type', () async { final response = await client.resend( email: testEmail, @@ -610,5 +619,19 @@ void main() { ); expect(mockClient.lastRequestBody?['type'], 'email_change'); }); + + test('verifyOTP() sends the captcha token under the captcha_token key', + () async { + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.sms, + captchaToken: 'captcha-token-123', + ); + + final security = mockClient.lastRequestBody?['gotrue_meta_security'] + as Map?; + expect(security?['captcha_token'], 'captcha-token-123'); + }); }); } diff --git a/packages/postgrest/lib/src/postgrest_builder.dart b/packages/postgrest/lib/src/postgrest_builder.dart index 96d6b6fb2..f24881e48 100644 --- a/packages/postgrest/lib/src/postgrest_builder.dart +++ b/packages/postgrest/lib/src/postgrest_builder.dart @@ -366,7 +366,7 @@ class PostgrestBuilder implements Future { return PostgrestResponse(data: _converter(null as R), count: 0) as T; } - return null as T; + return PostgrestResponse(data: null as S, count: 0) as T; } if (_converter != null) { return _converter(null as R) as T; diff --git a/packages/postgrest/test/maybe_single_test.dart b/packages/postgrest/test/maybe_single_test.dart new file mode 100644 index 000000000..d61ee0959 --- /dev/null +++ b/packages/postgrest/test/maybe_single_test.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:postgrest/postgrest.dart'; +import 'package:test/test.dart'; + +/// Mimics PostgREST returning the "0 rows" error that `maybeSingle()` treats as +/// an empty result rather than a failure. +class ZeroRowsHttpClient extends BaseClient { + @override + Future send(BaseRequest request) async { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'code': 'PGRST116', + 'details': 'Results contain 0 rows', + 'hint': null, + 'message': 'JSON object requested, multiple (or no) rows returned', + }))), + 406, + request: request, + ); + } +} + +void main() { + test('maybeSingle().count() returns null data and count 0 when no rows match', + () async { + final postgrest = PostgrestClient( + 'https://example.com', + httpClient: ZeroRowsHttpClient(), + ); + + final response = await postgrest + .from('users') + .update({'name': 'x'}) + .select() + .maybeSingle() + .count(); + + expect(response.data, isNull); + expect(response.count, 0); + }); +} diff --git a/packages/storage_client/lib/src/fetch.dart b/packages/storage_client/lib/src/fetch.dart index 64556563c..a688b9421 100644 --- a/packages/storage_client/lib/src/fetch.dart +++ b/packages/storage_client/lib/src/fetch.dart @@ -107,16 +107,16 @@ class Fetch { final contentType = fileOptions.contentType != null ? MediaType.parse(fileOptions.contentType!) : _parseMediaType(file.path); - final multipartFile = http.MultipartFile.fromBytes( - '', - file.readAsBytesSync(), - filename: file.path, - contentType: contentType, - ); + final bytes = file.readAsBytesSync(); return _handleMultipartRequest( method, url, - multipartFile, + () => http.MultipartFile.fromBytes( + '', + bytes, + filename: file.path, + contentType: contentType, + ), fileOptions, options, retryAttempts, @@ -135,18 +135,17 @@ class Fetch { ) { final contentType = fileOptions.contentType != null ? MediaType.parse(fileOptions.contentType!) - : _parseMediaType(url); - final multipartFile = http.MultipartFile.fromBytes( - '', - data, - // request fails with null filename so set it empty instead. - filename: '', - contentType: contentType, - ); + : _parseMediaType(Uri.parse(url).path); return _handleMultipartRequest( method, url, - multipartFile, + () => http.MultipartFile.fromBytes( + '', + data, + // request fails with null filename so set it empty instead. + filename: '', + contentType: contentType, + ), fileOptions, options, retryAttempts, @@ -157,7 +156,7 @@ class Fetch { Future _handleMultipartRequest( String method, String url, - MultipartFile multipartFile, + MultipartFile Function() createMultipartFile, FileOptions fileOptions, FetchOptions? options, int retryAttempts, @@ -169,7 +168,7 @@ class Fetch { http.MultipartRequest createRequest() { final request = http.MultipartRequest(method, Uri.parse(url)) ..headers.addAll(headers) - ..files.add(multipartFile) + ..files.add(createMultipartFile()) ..fields['cacheControl'] = fileOptions.cacheControl ..headers['x-upsert'] = fileOptions.upsert.toString(); if (fileOptions.metadata != null) { diff --git a/packages/storage_client/lib/src/storage_file_api.dart b/packages/storage_client/lib/src/storage_file_api.dart index b01595f15..400606d06 100644 --- a/packages/storage_client/lib/src/storage_file_api.dart +++ b/packages/storage_client/lib/src/storage_file_api.dart @@ -41,9 +41,7 @@ class StorageFileApi { } String _removeEmptyFolders(String path) { - return path - .replaceAll(RegExp(r'/^\/|\/$/g'), '') - .replaceAll(RegExp(r'/\/+/g'), '/'); + return path.replaceAll(RegExp(r'^/|/$'), '').replaceAll(RegExp(r'/+'), '/'); } /// Uploads a file to an existing bucket. diff --git a/packages/storage_client/test/fetch_test.dart b/packages/storage_client/test/fetch_test.dart new file mode 100644 index 000000000..9d97ce446 --- /dev/null +++ b/packages/storage_client/test/fetch_test.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart'; +import 'package:storage_client/storage_client.dart'; +import 'package:test/test.dart'; + +import 'custom_http_client.dart'; + +const storageUrl = 'http://localhost/storage/v1'; +const headers = {'Authorization': 'Bearer token'}; + +/// Client that finalizes (reads) the request body before failing, mimicking a +/// real HTTP client. This exercises [MultipartFile] finalization on every retry +/// attempt, which previously crashed because the same file instance was reused. +class FinalizingRetryHttpClient extends BaseClient { + FinalizingRetryHttpClient({this.failuresBeforeSuccess = 1}); + + final int failuresBeforeSuccess; + int attempts = 0; + + @override + Future send(BaseRequest request) async { + attempts++; + await request.finalize().drain(); + if (attempts <= failuresBeforeSuccess) { + throw ClientException('Offline'); + } + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'Key': 'public/a.txt'}))), + 201, + request: request, + ); + } +} + +void main() { + group('multipart uploads', () { + test('retries a binary upload after a failure that finalized the request', + () async { + final retryClient = FinalizingRetryHttpClient(failuresBeforeSuccess: 1); + final client = SupabaseStorageClient( + storageUrl, + headers, + httpClient: retryClient, + retryAttempts: 3, + ); + + final result = await client + .from('bucket') + .uploadBinary('folder/file.png', Uint8List.fromList([1, 2, 3])); + + expect(result, 'public/a.txt'); + expect(retryClient.attempts, 2); + }); + + test('detects content type from the path of a binary signed url upload', + () async { + final mockClient = CustomHttpClient(); + mockClient.response = {}; + mockClient.statusCode = 200; + final client = SupabaseStorageClient( + storageUrl, + headers, + httpClient: mockClient, + ); + + await client.from('bucket').uploadBinaryToSignedUrl( + 'folder/image.png', + 'signed-token', + Uint8List.fromList([1, 2, 3]), + ); + + final request = mockClient.receivedRequests.single as MultipartRequest; + expect(request.files.single.contentType.mimeType, 'image/png'); + }); + }); + + group('path normalization', () { + test('removes leading, trailing and duplicate slashes from the path', + () async { + final mockClient = CustomHttpClient(); + mockClient.response = {}; + mockClient.statusCode = 200; + final client = SupabaseStorageClient( + storageUrl, + headers, + httpClient: mockClient, + ); + + final cleanPath = await client.from('bucket').uploadBinaryToSignedUrl( + '/folder//image.png/', + 'signed-token', + Uint8List.fromList([1]), + ); + + expect(cleanPath, 'folder/image.png'); + + final requestPath = mockClient.receivedRequests.single.url.path; + expect(requestPath, endsWith('/bucket/folder/image.png')); + expect(requestPath.contains('//'), isFalse); + }); + }); +} diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index dbd9d2333..9f62faf86 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -113,7 +113,10 @@ class SupabaseClient { // manually unsubscribe and resubscribe to all channels. realtime.headers ..clear() - ..addAll(_headers); + ..addAll({ + 'apikey': _supabaseKey, + ..._headers, + }); } /// {@macro supabase_client} @@ -213,7 +216,7 @@ class SupabaseClient { Map? params, get = false, }) { - rest.headers.addAll({...rest.headers, ...headers}); + rest.headers.addAll(headers); return rest.rpc(fn, params: params, get: get); } diff --git a/packages/supabase/lib/src/supabase_query_schema.dart b/packages/supabase/lib/src/supabase_query_schema.dart index 3647ccf9a..a898f71c7 100644 --- a/packages/supabase/lib/src/supabase_query_schema.dart +++ b/packages/supabase/lib/src/supabase_query_schema.dart @@ -54,7 +54,7 @@ class SupabaseQuerySchema { Map? params, bool get = false, }) { - _rest.headers.addAll({..._rest.headers, ..._headers}); + _rest.headers.addAll(_headers); return _rest.rpc( fn, params: params, diff --git a/packages/supabase/test/client_test.dart b/packages/supabase/test/client_test.dart index d8228ff5c..e51672aa0 100644 --- a/packages/supabase/test/client_test.dart +++ b/packages/supabase/test/client_test.dart @@ -305,6 +305,13 @@ void main() { expect(supabase.headers['X-Client-Info'], startsWith('supabase-dart/')); }); + test('should preserve apikey on realtime headers when setting headers', + () { + supabase.headers = {'Custom-Header': 'custom-value'}; + + expect(supabase.realtime.headers['apikey'], supabaseKey); + }); + test('should not update auth headers when using custom access token', () { final customTokenClient = SupabaseClient( supabaseUrl, diff --git a/packages/supabase_flutter/lib/src/supabase.dart b/packages/supabase_flutter/lib/src/supabase.dart index e5dfac3f8..c50676880 100644 --- a/packages/supabase_flutter/lib/src/supabase.dart +++ b/packages/supabase_flutter/lib/src/supabase.dart @@ -176,7 +176,10 @@ class Supabase { bool _debugEnable = false; /// Wraps the `recoverSession()` call so that it can be terminated when `dispose()` is called - late CancelableOperation _restoreSessionCancellableOperation; + /// + /// Only set when [Supabase.initialize] is called without a custom + /// `accessToken`, since session recovery is skipped for third-party auth. + CancelableOperation? _restoreSessionCancellableOperation; // Listener for app lifecycle events to handle Realtime reconnection. AppLifecycleListener? _lifecycleListener; @@ -195,7 +198,7 @@ class Supabase { /// Dispose the instance to free up resources. Future dispose() async { _targetLifecycleState = null; - await _restoreSessionCancellableOperation.cancel(); + await _restoreSessionCancellableOperation?.cancel(); _logSubscription?.cancel(); client.dispose(); _instance._supabaseAuth?.dispose(); diff --git a/packages/supabase_flutter/test/dispose_test.dart b/packages/supabase_flutter/test/dispose_test.dart new file mode 100644 index 000000000..bd9620680 --- /dev/null +++ b/packages/supabase_flutter/test/dispose_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'widget_test_stubs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // This must be the first (and only) initialization in this isolate. The + // `Supabase` singleton keeps `_restoreSessionCancellableOperation` across + // initialize/dispose cycles, so any earlier initialization without a custom + // `accessToken` would assign it and mask the regression this test guards. + test('dispose() does not throw when initialized with a custom access token', + () async { + SharedPreferences.setMockInitialValues({}); + mockAppLink(); + + await Supabase.initialize( + url: '', + publishableKey: '', + accessToken: () async => 'custom-access-token', + ); + + await expectLater(Supabase.instance.dispose(), completes); + }); +}