Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/gotrue/lib/src/gotrue_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
23 changes: 23 additions & 0 deletions packages/gotrue/test/otp_mock_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,15 @@ void main() {
expect(response, isA<ResendResponse>());
});

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,
Expand Down Expand Up @@ -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<String, dynamic>?;
expect(security?['captcha_token'], 'captcha-token-123');
});
});
}
2 changes: 1 addition & 1 deletion packages/postgrest/lib/src/postgrest_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ class PostgrestBuilder<T, S, R> implements Future<T> {
return PostgrestResponse<S>(data: _converter(null as R), count: 0)
as T;
}
return null as T;
return PostgrestResponse<S>(data: null as S, count: 0) as T;
}
if (_converter != null) {
return _converter(null as R) as T;
Expand Down
43 changes: 43 additions & 0 deletions packages/postgrest/test/maybe_single_test.dart
Original file line number Diff line number Diff line change
@@ -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<StreamedResponse> 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);
});
}
35 changes: 17 additions & 18 deletions packages/storage_client/lib/src/fetch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -157,7 +156,7 @@ class Fetch {
Future<dynamic> _handleMultipartRequest(
String method,
String url,
MultipartFile multipartFile,
MultipartFile Function() createMultipartFile,
FileOptions fileOptions,
FetchOptions? options,
int retryAttempts,
Expand All @@ -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) {
Expand Down
4 changes: 1 addition & 3 deletions packages/storage_client/lib/src/storage_file_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
104 changes: 104 additions & 0 deletions packages/storage_client/test/fetch_test.dart
Original file line number Diff line number Diff line change
@@ -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<StreamedResponse> send(BaseRequest request) async {
attempts++;
await request.finalize().drain<void>();
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 = <String, dynamic>{};
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 = <String, dynamic>{};
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);
});
});
}
7 changes: 5 additions & 2 deletions packages/supabase/lib/src/supabase_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -213,7 +216,7 @@ class SupabaseClient {
Map<String, dynamic>? params,
get = false,
}) {
rest.headers.addAll({...rest.headers, ...headers});
rest.headers.addAll(headers);
return rest.rpc(fn, params: params, get: get);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/supabase/lib/src/supabase_query_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class SupabaseQuerySchema {
Map<String, dynamic>? params,
bool get = false,
}) {
_rest.headers.addAll({..._rest.headers, ..._headers});
_rest.headers.addAll(_headers);
return _rest.rpc(
fn,
params: params,
Expand Down
7 changes: 7 additions & 0 deletions packages/supabase/test/client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions packages/supabase_flutter/lib/src/supabase.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -195,7 +198,7 @@ class Supabase {
/// Dispose the instance to free up resources.
Future<void> dispose() async {
_targetLifecycleState = null;
await _restoreSessionCancellableOperation.cancel();
await _restoreSessionCancellableOperation?.cancel();
_logSubscription?.cancel();
client.dispose();
_instance._supabaseAuth?.dispose();
Expand Down
27 changes: 27 additions & 0 deletions packages/supabase_flutter/test/dispose_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading