diff --git a/.github/workflows/dart-tests.yaml b/.github/workflows/dart-tests.yaml index 7149589ba2..20e4207838 100644 --- a/.github/workflows/dart-tests.yaml +++ b/.github/workflows/dart-tests.yaml @@ -308,3 +308,11 @@ jobs: - name: Run unit tests run: dart test --concurrency=1 working-directory: ${{ matrix.path }} + + integration_test_flutter: + name: Serverpod integration tests (flutter) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Run integration tests in Docker + run: util/run_tests_flutter_integration diff --git a/examples/auth_example/auth_example_flutter/lib/src/widgets/account_page.dart b/examples/auth_example/auth_example_flutter/lib/src/widgets/account_page.dart index 1bce9b9ae3..427a5d1090 100644 --- a/examples/auth_example/auth_example_flutter/lib/src/widgets/account_page.dart +++ b/examples/auth_example/auth_example_flutter/lib/src/widgets/account_page.dart @@ -24,7 +24,7 @@ class AccountPage extends StatelessWidget { padding: const EdgeInsets.all(16), child: ElevatedButton( onPressed: () { - sessionManager.signOut(); + sessionManager.signOutDevice(); }, child: const Text('Sign out'), ), diff --git a/examples/chat/chat_flutter/lib/src/main_page.dart b/examples/chat/chat_flutter/lib/src/main_page.dart index 0634eb6bee..df16c4d3c7 100644 --- a/examples/chat/chat_flutter/lib/src/main_page.dart +++ b/examples/chat/chat_flutter/lib/src/main_page.dart @@ -134,6 +134,6 @@ class _ChannelDrawer extends StatelessWidget { } void _signOut() { - sessionManager.signOut(); + sessionManager.signOutDevice(); } } diff --git a/modules/serverpod_auth/serverpod_auth_client/lib/src/protocol/client.dart b/modules/serverpod_auth/serverpod_auth_client/lib/src/protocol/client.dart index 8af53fd46f..b2581a10c5 100644 --- a/modules/serverpod_auth/serverpod_auth_client/lib/src/protocol/client.dart +++ b/modules/serverpod_auth/serverpod_auth_client/lib/src/protocol/client.dart @@ -226,13 +226,31 @@ class EndpointStatus extends _i1.EndpointRef { {}, ); - /// Signs out a user. + /// **[Deprecated]** Signs out a user from all devices. + /// Use `signOutDevice` to sign out a single device + /// or `signOutAllDevices` to sign out all devices. + @Deprecated( + 'Use signOutDevice to sign out a single device or signOutAllDevices to sign out all devices. This method will be removed in future releases.') _i2.Future signOut() => caller.callServerEndpoint( 'serverpod_auth.status', 'signOut', {}, ); + /// Signs out a user from the current device. + _i2.Future signOutDevice() => caller.callServerEndpoint( + 'serverpod_auth.status', + 'signOutDevice', + {}, + ); + + /// Signs out a user from all active devices. + _i2.Future signOutAllDevices() => caller.callServerEndpoint( + 'serverpod_auth.status', + 'signOutAllDevices', + {}, + ); + /// Gets the [UserInfo] for a signed in user, or null if the user is currently /// not signed in with the server. _i2.Future<_i3.UserInfo?> getUserInfo() => diff --git a/modules/serverpod_auth/serverpod_auth_server/analysis_options.yaml b/modules/serverpod_auth/serverpod_auth_server/analysis_options.yaml index d1161507ac..337af08ec9 100644 --- a/modules/serverpod_auth/serverpod_auth_server/analysis_options.yaml +++ b/modules/serverpod_auth/serverpod_auth_server/analysis_options.yaml @@ -1,2 +1 @@ -include: package:serverpod_lints/public.yaml - +include: package:serverpod_lints/public.yaml \ No newline at end of file diff --git a/modules/serverpod_auth/serverpod_auth_server/config/generator.yaml b/modules/serverpod_auth/serverpod_auth_server/config/generator.yaml index 02f566ecc7..2357b24775 100644 --- a/modules/serverpod_auth/serverpod_auth_server/config/generator.yaml +++ b/modules/serverpod_auth/serverpod_auth_server/config/generator.yaml @@ -2,3 +2,4 @@ type: module nickname: auth client_package_path: ../serverpod_auth_client +server_test_tools_path: test/integration/test_tools diff --git a/modules/serverpod_auth/serverpod_auth_server/config/passwords.yaml b/modules/serverpod_auth/serverpod_auth_server/config/passwords.yaml new file mode 100644 index 0000000000..fc7213eff6 --- /dev/null +++ b/modules/serverpod_auth/serverpod_auth_server/config/passwords.yaml @@ -0,0 +1,4 @@ +test: + database: 'password' + redis: 'password' + serviceSecret: 'super_SECRET_password' diff --git a/modules/serverpod_auth/serverpod_auth_server/config/test.yaml b/modules/serverpod_auth/serverpod_auth_server/config/test.yaml new file mode 100644 index 0000000000..51af34d88c --- /dev/null +++ b/modules/serverpod_auth/serverpod_auth_server/config/test.yaml @@ -0,0 +1,28 @@ +apiServer: + port: 8080 + publicHost: serverpod_test_server + publicPort: 8080 + publicScheme: http + +insightsServer: + port: 8081 + publicHost: serverpod_test_server + publicPort: 8081 + publicScheme: http + +webServer: + port: 8082 + publicHost: serverpod_test_server + publicPort: 8082 + publicScheme: http + +database: + host: postgres + port: 5432 + name: serverpod_test + user: postgres + +redis: + enabled: true + host: redis + port: 6379 diff --git a/modules/serverpod_auth/serverpod_auth_server/lib/src/business/authentication_handler.dart b/modules/serverpod_auth/serverpod_auth_server/lib/src/business/authentication_handler.dart index 3afb8583d1..3cae2ad235 100644 --- a/modules/serverpod_auth/serverpod_auth_server/lib/src/business/authentication_handler.dart +++ b/modules/serverpod_auth/serverpod_auth_server/lib/src/business/authentication_handler.dart @@ -23,7 +23,7 @@ Future authenticationHandler( enableLogging: false, ); - var authKey = await tempSession.db.findById(keyId); + var authKey = await AuthKey.db.findById(tempSession, keyId); await tempSession.close(); if (authKey == null) return null; @@ -41,7 +41,11 @@ Future authenticationHandler( for (var scopeName in authKey.scopeNames) { scopes.add(Scope(scopeName)); } - return AuthenticationInfo(authKey.userId, scopes); + return AuthenticationInfo( + authKey.userId, + scopes, + authId: keyIdStr, + ); } catch (exception, stackTrace) { stderr.writeln('Failed authentication: $exception'); stderr.writeln('$stackTrace'); diff --git a/modules/serverpod_auth/serverpod_auth_server/lib/src/business/config.dart b/modules/serverpod_auth/serverpod_auth_server/lib/src/business/config.dart index 2aee754ee0..3833a3c20a 100644 --- a/modules/serverpod_auth/serverpod_auth_server/lib/src/business/config.dart +++ b/modules/serverpod_auth/serverpod_auth_server/lib/src/business/config.dart @@ -47,6 +47,15 @@ typedef PasswordHashValidator = Future Function( void Function(Object e)? onError, }); +/// Enum to define the sign-out behavior for the legacy sign out endpoint. +enum SignOutBehavior { + /// Sign out the user from all active devices. + allDevices, + + /// Sign out the user from the current device only. + currentDevice, +} + /// Configuration options for the Auth module. class AuthConfig { static AuthConfig _config = AuthConfig(); @@ -157,6 +166,12 @@ class AuthConfig { /// Create a custom validation for the password in combinaison with [PasswordHashGenerator] final PasswordHashValidator passwordHashValidator; + /// Defines the legacy sign-out behavior for users. + /// + /// - [SignOutBehavior.allDevices]: Users will be signed out from all active devices. + /// - [SignOutBehavior.currentDevice]: Users will be signed out from the current device only. + final SignOutBehavior legacyUserSignOutBehavior; + /// Creates a new Auth configuration. Use the [set] method to replace the /// default settings. Defaults to `config/firebase_service_account_key.json`. AuthConfig({ @@ -189,6 +204,7 @@ class AuthConfig { this.allowUnsecureRandom = false, this.passwordHashGenerator = defaultGeneratePasswordHash, this.passwordHashValidator = defaultValidatePasswordHash, + this.legacyUserSignOutBehavior = SignOutBehavior.allDevices, }) { if (validationCodeLength < 8) { stderr.writeln( diff --git a/modules/serverpod_auth/serverpod_auth_server/lib/src/business/user_authentication.dart b/modules/serverpod_auth/serverpod_auth_server/lib/src/business/user_authentication.dart index c2a464af9f..3d81f554ed 100644 --- a/modules/serverpod_auth/serverpod_auth_server/lib/src/business/user_authentication.dart +++ b/modules/serverpod_auth/serverpod_auth_server/lib/src/business/user_authentication.dart @@ -4,20 +4,23 @@ import 'package:serverpod_auth_server/module.dart'; import 'package:serverpod_auth_server/src/business/authentication_util.dart'; import 'package:serverpod_shared/serverpod_shared.dart'; -/// Collects methods for authenticating users. +/// Provides methods for authenticating users. class UserAuthentication { /// Signs in an user to the server. The user should have been authenticated /// before signing them in. Send the AuthKey.id and key to the client and /// use that to authenticate in future calls. In most situations you should /// use one of the auth providers instead of this method. + /// + /// - `updateSession`: If set to `true`, the session will be updated with + /// the authenticated user's information. The default is `true`. static Future signInUser( Session session, int userId, String method, { Set scopes = const {}, + bool updateSession = true, }) async { var signInSalt = session.passwords['authKeySalt'] ?? defaultAuthKeySalt; - var key = generateRandomString(); var hash = hashString(signInSalt, key); @@ -34,21 +37,91 @@ class UserAuthentication { method: method, ); - session.updateAuthenticated(AuthenticationInfo(userId, scopes)); var result = await AuthKey.db.insertRow(session, authKey); - return result.copyWith(key: key); + + if (updateSession) { + session.updateAuthenticated( + AuthenticationInfo( + userId, + scopes, + authId: '${result.id}', + ), + ); + } + return result; } /// Signs out a user from the server and deletes all authentication keys. /// This means that the user will be signed out from all connected devices. - static Future signOutUser(Session session, {int? userId}) async { + /// If the user being signed out is the currently authenticated user, the + /// session's authentication information will be cleared. + /// + /// Note: The method will fail silently if no authentication information is + /// found for the user. + static Future signOutUser( + Session session, { + int? userId, + }) async { userId ??= (await session.authenticated)?.userId; if (userId == null) return; - await session.db - .deleteWhere(where: AuthKey.t.userId.equals(userId)); - await session.messages - .authenticationRevoked(userId, RevokedAuthenticationUser()); - session.updateAuthenticated(null); + // Delete all authentication keys for the user + var auths = await AuthKey.db.deleteWhere( + session, + where: (row) => row.userId.equals(userId), + ); + + if (auths.isEmpty) return; + + // Notify clients about the revoked authentication for the user + await session.messages.authenticationRevoked( + userId, + RevokedAuthenticationUser(), + ); + + // Clear session authentication if the signed-out user is the currently + // authenticated user + var authInfo = await session.authenticated; + if (userId == authInfo?.userId) { + session.updateAuthenticated(null); + } + } + + /// Signs out the user from the current device by deleting the specific + /// authentication key. This does not affect the user's sessions on other + /// devices. If the user being signed out is the currently authenticated user, + /// the session's authentication information will be cleared. + /// + /// Note: The method will fail silently if no authentication information is + /// found for the key. + static Future revokeAuthKey( + Session session, { + required String authKeyId, + }) async { + int? id = int.tryParse(authKeyId); + if (id == null) return; + + // Delete the authentication key for the current device + var auths = await AuthKey.db.deleteWhere( + session, + where: (row) => row.id.equals(id), + ); + + if (auths.isEmpty) return; + var auth = auths.first; + + // Notify the client about the revoked authentication for the specific + // auth key + await session.messages.authenticationRevoked( + auth.userId, + RevokedAuthenticationAuthId(authId: authKeyId), + ); + + // Clear session authentication if the signed-out user is the currently + // authenticated user + var authInfo = await session.authenticated; + if (auth.userId == authInfo?.userId) { + session.updateAuthenticated(null); + } } } diff --git a/modules/serverpod_auth/serverpod_auth_server/lib/src/business/users.dart b/modules/serverpod_auth/serverpod_auth_server/lib/src/business/users.dart index 859f2e9387..418f6f99ae 100644 --- a/modules/serverpod_auth/serverpod_auth_server/lib/src/business/users.dart +++ b/modules/serverpod_auth/serverpod_auth_server/lib/src/business/users.dart @@ -184,7 +184,10 @@ class Users { await session.db.updateRow(userInfo); await invalidateCacheForUser(session, userId); // Sign out user - await UserAuthentication.signOutUser(session, userId: userId); + await UserAuthentication.signOutUser( + session, + userId: userId, + ); } /// Unblocks a user so that they can log in again. diff --git a/modules/serverpod_auth/serverpod_auth_server/lib/src/endpoints/status_endpoint.dart b/modules/serverpod_auth/serverpod_auth_server/lib/src/endpoints/status_endpoint.dart index b0673cd06f..aaf11521fb 100644 --- a/modules/serverpod_auth/serverpod_auth_server/lib/src/endpoints/status_endpoint.dart +++ b/modules/serverpod_auth/serverpod_auth_server/lib/src/endpoints/status_endpoint.dart @@ -16,9 +16,56 @@ class StatusEndpoint extends Endpoint { return userId != null; } - /// Signs out a user. + /// **[Deprecated]** Signs out a user from all devices. + /// Use `signOutDevice` to sign out a single device + /// or `signOutAllDevices` to sign out all devices. + @Deprecated( + 'Use signOutDevice to sign out a single device or signOutAllDevices to sign out all devices. ' + 'This method will be removed in future releases.', + ) Future signOut(Session session) async { - await UserAuthentication.signOutUser(session); + var authInfo = await session.authenticated; + if (authInfo == null) return; + + switch (AuthConfig.current.legacyUserSignOutBehavior) { + case SignOutBehavior.currentDevice: + var authKeyId = authInfo.authId; + if (authKeyId == null) return; + + return UserAuthentication.revokeAuthKey( + session, + authKeyId: authKeyId, + ); + case SignOutBehavior.allDevices: + return UserAuthentication.signOutUser( + session, + userId: authInfo.userId, + ); + } + } + + /// Signs out a user from the current device. + Future signOutDevice(Session session) async { + var authInfo = await session.authenticated; + var authKeyId = authInfo?.authId; + if (authKeyId == null) return; + + return UserAuthentication.revokeAuthKey( + session, + authKeyId: authKeyId, + ); + } + + /// Signs out a user from all active devices. + Future signOutAllDevices(Session session) async { + var authInfo = await session.authenticated; + var userId = authInfo?.userId; + if (userId == null) return; + + return UserAuthentication.signOutUser( + session, + userId: userId, + ); } /// Gets the [UserInfo] for a signed in user, or null if the user is currently @@ -26,6 +73,7 @@ class StatusEndpoint extends Endpoint { Future getUserInfo(Session session) async { var userId = (await session.authenticated)?.userId; if (userId == null) return null; + return await UserInfo.db.findById(session, userId); } diff --git a/modules/serverpod_auth/serverpod_auth_server/lib/src/generated/endpoints.dart b/modules/serverpod_auth/serverpod_auth_server/lib/src/generated/endpoints.dart index 844483e63b..e022b667e4 100644 --- a/modules/serverpod_auth/serverpod_auth_server/lib/src/generated/endpoints.dart +++ b/modules/serverpod_auth/serverpod_auth_server/lib/src/generated/endpoints.dart @@ -396,7 +396,30 @@ class Endpoints extends _i1.EndpointDispatch { _i1.Session session, Map params, ) async => - (endpoints['status'] as _i7.StatusEndpoint).signOut(session), + (endpoints['status'] as _i7.StatusEndpoint) + . +// ignore: deprecated_member_use_from_same_package + signOut(session), + ), + 'signOutDevice': _i1.MethodConnector( + name: 'signOutDevice', + params: {}, + call: ( + _i1.Session session, + Map params, + ) async => + (endpoints['status'] as _i7.StatusEndpoint) + .signOutDevice(session), + ), + 'signOutAllDevices': _i1.MethodConnector( + name: 'signOutAllDevices', + params: {}, + call: ( + _i1.Session session, + Map params, + ) async => + (endpoints['status'] as _i7.StatusEndpoint) + .signOutAllDevices(session), ), 'getUserInfo': _i1.MethodConnector( name: 'getUserInfo', diff --git a/modules/serverpod_auth/serverpod_auth_server/lib/src/generated/protocol.yaml b/modules/serverpod_auth/serverpod_auth_server/lib/src/generated/protocol.yaml index e0c70b2b06..f460bc79ae 100644 --- a/modules/serverpod_auth/serverpod_auth_server/lib/src/generated/protocol.yaml +++ b/modules/serverpod_auth/serverpod_auth_server/lib/src/generated/protocol.yaml @@ -19,6 +19,8 @@ google: status: - isSignedIn: - signOut: + - signOutDevice: + - signOutAllDevices: - getUserInfo: - getUserSettingsConfig: user: diff --git a/modules/serverpod_auth/serverpod_auth_server/pubspec.yaml b/modules/serverpod_auth/serverpod_auth_server/pubspec.yaml index 7f6e52769e..5aded145a7 100644 --- a/modules/serverpod_auth/serverpod_auth_server/pubspec.yaml +++ b/modules/serverpod_auth/serverpod_auth_server/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: dev_dependencies: serverpod_lints: 2.1.5 test: ^1.24.2 + serverpod_test: 2.1.5 dependency_overrides: serverpod: @@ -37,6 +38,8 @@ dependency_overrides: path: ../../../packages/serverpod_lints serverpod_shared: path: ../../../packages/serverpod_shared + serverpod_test: + path: ../../../packages/serverpod_test false_secrets: - test/firebase/firebase_auth_mock.dart diff --git a/modules/serverpod_auth/serverpod_auth_server/test/integration/test_tools/serverpod_test_tools.dart b/modules/serverpod_auth/serverpod_auth_server/test/integration/test_tools/serverpod_test_tools.dart new file mode 100644 index 0000000000..3dc0dc6f71 --- /dev/null +++ b/modules/serverpod_auth/serverpod_auth_server/test/integration/test_tools/serverpod_test_tools.dart @@ -0,0 +1,795 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters + +// ignore_for_file: no_leading_underscores_for_local_identifiers + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:serverpod_test/serverpod_test.dart' as _i1; +import 'package:serverpod/serverpod.dart' as _i2; +import 'dart:async' as _i3; +import 'package:serverpod_auth_server/src/generated/user_info.dart' as _i4; +import 'package:serverpod_auth_server/src/generated/authentication_response.dart' + as _i5; +import 'package:serverpod_auth_server/src/generated/apple_auth_info.dart' + as _i6; +import 'package:serverpod_auth_server/src/generated/user_settings_config.dart' + as _i7; +import 'dart:typed_data' as _i8; +import 'package:serverpod_auth_server/src/generated/protocol.dart'; +import 'package:serverpod_auth_server/src/generated/endpoints.dart'; +export 'package:serverpod_test/serverpod_test_public_exports.dart'; + +@_i1.isTestGroup +void withServerpod( + String testGroupName, + _i1.TestClosure testClosure, { + String? runMode, + bool? enableSessionLogging, + _i2.ServerpodLoggingMode? serverpodLoggingMode, + List? testGroupTagsOverride, + Duration? serverpodStartTimeout, + _i1.RollbackDatabase? rollbackDatabase, + bool? applyMigrations, +}) { + _i1.buildWithServerpod<_InternalTestEndpoints>( + testGroupName, + _i1.TestServerpod( + testEndpoints: _InternalTestEndpoints(), + endpoints: Endpoints(), + serializationManager: Protocol(), + runMode: runMode, + applyMigrations: applyMigrations, + isDatabaseEnabled: true, + serverpodLoggingMode: serverpodLoggingMode, + ), + maybeRollbackDatabase: rollbackDatabase, + maybeEnableSessionLogging: enableSessionLogging, + maybeTestGroupTagsOverride: testGroupTagsOverride, + maybeServerpodStartTimeout: serverpodStartTimeout, + )(testClosure); +} + +class TestEndpoints { + late final _AdminEndpoint admin; + + late final _AppleEndpoint apple; + + late final _EmailEndpoint email; + + late final _FirebaseEndpoint firebase; + + late final _GoogleEndpoint google; + + late final _StatusEndpoint status; + + late final _UserEndpoint user; +} + +class _InternalTestEndpoints extends TestEndpoints + implements _i1.InternalTestEndpoints { + @override + void initialize( + _i2.SerializationManager serializationManager, + _i2.EndpointDispatch endpoints, + ) { + admin = _AdminEndpoint( + endpoints, + serializationManager, + ); + apple = _AppleEndpoint( + endpoints, + serializationManager, + ); + email = _EmailEndpoint( + endpoints, + serializationManager, + ); + firebase = _FirebaseEndpoint( + endpoints, + serializationManager, + ); + google = _GoogleEndpoint( + endpoints, + serializationManager, + ); + status = _StatusEndpoint( + endpoints, + serializationManager, + ); + user = _UserEndpoint( + endpoints, + serializationManager, + ); + } +} + +class _AdminEndpoint { + _AdminEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i4.UserInfo?> getUserInfo( + _i1.TestSessionBuilder sessionBuilder, + int userId, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'getUserInfo', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'getUserInfo', + parameters: _i1.testObjectToJson({'userId': userId}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future<_i4.UserInfo?>); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future blockUser( + _i1.TestSessionBuilder sessionBuilder, + int userId, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'blockUser', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'blockUser', + parameters: _i1.testObjectToJson({'userId': userId}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future unblockUser( + _i1.TestSessionBuilder sessionBuilder, + int userId, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'unblockUser', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'unblockUser', + parameters: _i1.testObjectToJson({'userId': userId}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } +} + +class _AppleEndpoint { + _AppleEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i5.AuthenticationResponse> authenticate( + _i1.TestSessionBuilder sessionBuilder, + _i6.AppleAuthInfo authInfo, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'apple', + method: 'authenticate', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'apple', + methodName: 'authenticate', + parameters: _i1.testObjectToJson({'authInfo': authInfo}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future<_i5.AuthenticationResponse>); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } +} + +class _EmailEndpoint { + _EmailEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i5.AuthenticationResponse> authenticate( + _i1.TestSessionBuilder sessionBuilder, + String email, + String password, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'email', + method: 'authenticate', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'email', + methodName: 'authenticate', + parameters: _i1.testObjectToJson({ + 'email': email, + 'password': password, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future<_i5.AuthenticationResponse>); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future changePassword( + _i1.TestSessionBuilder sessionBuilder, + String oldPassword, + String newPassword, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'email', + method: 'changePassword', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'email', + methodName: 'changePassword', + parameters: _i1.testObjectToJson({ + 'oldPassword': oldPassword, + 'newPassword': newPassword, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future initiatePasswordReset( + _i1.TestSessionBuilder sessionBuilder, + String email, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'email', + method: 'initiatePasswordReset', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'email', + methodName: 'initiatePasswordReset', + parameters: _i1.testObjectToJson({'email': email}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future resetPassword( + _i1.TestSessionBuilder sessionBuilder, + String verificationCode, + String password, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'email', + method: 'resetPassword', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'email', + methodName: 'resetPassword', + parameters: _i1.testObjectToJson({ + 'verificationCode': verificationCode, + 'password': password, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future createAccountRequest( + _i1.TestSessionBuilder sessionBuilder, + String userName, + String email, + String password, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'email', + method: 'createAccountRequest', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'email', + methodName: 'createAccountRequest', + parameters: _i1.testObjectToJson({ + 'userName': userName, + 'email': email, + 'password': password, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future<_i4.UserInfo?> createAccount( + _i1.TestSessionBuilder sessionBuilder, + String email, + String verificationCode, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'email', + method: 'createAccount', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'email', + methodName: 'createAccount', + parameters: _i1.testObjectToJson({ + 'email': email, + 'verificationCode': verificationCode, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future<_i4.UserInfo?>); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } +} + +class _FirebaseEndpoint { + _FirebaseEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i5.AuthenticationResponse> authenticate( + _i1.TestSessionBuilder sessionBuilder, + String idToken, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'firebase', + method: 'authenticate', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'firebase', + methodName: 'authenticate', + parameters: _i1.testObjectToJson({'idToken': idToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future<_i5.AuthenticationResponse>); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } +} + +class _GoogleEndpoint { + _GoogleEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i5.AuthenticationResponse> authenticateWithServerAuthCode( + _i1.TestSessionBuilder sessionBuilder, + String authenticationCode, + String? redirectUri, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'google', + method: 'authenticateWithServerAuthCode', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'google', + methodName: 'authenticateWithServerAuthCode', + parameters: _i1.testObjectToJson({ + 'authenticationCode': authenticationCode, + 'redirectUri': redirectUri, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future<_i5.AuthenticationResponse>); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future<_i5.AuthenticationResponse> authenticateWithIdToken( + _i1.TestSessionBuilder sessionBuilder, + String idToken, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'google', + method: 'authenticateWithIdToken', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'google', + methodName: 'authenticateWithIdToken', + parameters: _i1.testObjectToJson({'idToken': idToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future<_i5.AuthenticationResponse>); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } +} + +class _StatusEndpoint { + _StatusEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future isSignedIn(_i1.TestSessionBuilder sessionBuilder) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'status', + method: 'isSignedIn', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'status', + methodName: 'isSignedIn', + parameters: _i1.testObjectToJson({}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future signOut(_i1.TestSessionBuilder sessionBuilder) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'status', + method: 'signOut', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'status', + methodName: 'signOut', + parameters: _i1.testObjectToJson({}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future signOutDevice(_i1.TestSessionBuilder sessionBuilder) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'status', + method: 'signOutDevice', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'status', + methodName: 'signOutDevice', + parameters: _i1.testObjectToJson({}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future signOutAllDevices( + _i1.TestSessionBuilder sessionBuilder) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'status', + method: 'signOutAllDevices', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'status', + methodName: 'signOutAllDevices', + parameters: _i1.testObjectToJson({}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future<_i4.UserInfo?> getUserInfo( + _i1.TestSessionBuilder sessionBuilder) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'status', + method: 'getUserInfo', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'status', + methodName: 'getUserInfo', + parameters: _i1.testObjectToJson({}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future<_i4.UserInfo?>); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future<_i7.UserSettingsConfig> getUserSettingsConfig( + _i1.TestSessionBuilder sessionBuilder) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'status', + method: 'getUserSettingsConfig', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'status', + methodName: 'getUserSettingsConfig', + parameters: _i1.testObjectToJson({}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future<_i7.UserSettingsConfig>); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } +} + +class _UserEndpoint { + _UserEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future removeUserImage( + _i1.TestSessionBuilder sessionBuilder) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'user', + method: 'removeUserImage', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'user', + methodName: 'removeUserImage', + parameters: _i1.testObjectToJson({}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future setUserImage( + _i1.TestSessionBuilder sessionBuilder, + _i8.ByteData image, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'user', + method: 'setUserImage', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'user', + methodName: 'setUserImage', + parameters: _i1.testObjectToJson({'image': image}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future changeUserName( + _i1.TestSessionBuilder sessionBuilder, + String userName, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'user', + method: 'changeUserName', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'user', + methodName: 'changeUserName', + parameters: _i1.testObjectToJson({'userName': userName}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } + + _i3.Future changeFullName( + _i1.TestSessionBuilder sessionBuilder, + String fullName, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'user', + method: 'changeFullName', + ); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'user', + methodName: 'changeFullName', + parameters: _i1.testObjectToJson({'fullName': fullName}), + serializationManager: _serializationManager, + ); + var _localReturnValue = await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) as _i3.Future); + await _localUniqueSession.close(); + return _localReturnValue; + }); + } +} diff --git a/modules/serverpod_auth/serverpod_auth_server/test_integration/signout/signout_legacy_option_test.dart b/modules/serverpod_auth/serverpod_auth_server/test_integration/signout/signout_legacy_option_test.dart new file mode 100644 index 0000000000..b77007191b --- /dev/null +++ b/modules/serverpod_auth/serverpod_auth_server/test_integration/signout/signout_legacy_option_test.dart @@ -0,0 +1,128 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:serverpod_auth_server/serverpod_auth_server.dart'; +import 'package:test/test.dart'; +import '../../test/integration/test_tools/serverpod_test_tools.dart'; + +void main() { + var userId = 1; + withServerpod( + 'Given no legacy user sign-out option defined and user signed in to multiple devices', + (sessionBuilder, endpoints) { + late Session session; + late List authKeys; + + setUp(() async { + session = sessionBuilder.build(); + authKeys = await _signInUserToMultipleDevices(session, userId); + }); + + test( + 'when signing out user then user is signed out of all devices', + () async { + sessionBuilder = sessionBuilder.copyWith( + authentication: AuthenticationOverride.authenticationInfo( + authKeys.first.userId, + {}, + authId: '${authKeys.first.id}', + ), + ); + + await endpoints.status.signOut(sessionBuilder); + + authKeys = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId), + ); + + expect(authKeys, isEmpty); + }, + ); + }, + ); + + withServerpod( + 'Given legacy sign-out option set to all devices and user signed in to multiple devices', + (sessionBuilder, endpoints) { + late Session session; + late List authKeys; + + setUp(() async { + session = sessionBuilder.build(); + AuthConfig.set(AuthConfig( + legacyUserSignOutBehavior: SignOutBehavior.allDevices, + )); + + authKeys = await _signInUserToMultipleDevices(session, userId); + }); + + test( + 'when signing out user then user is signed out of all devices', + () async { + sessionBuilder = sessionBuilder.copyWith( + authentication: AuthenticationOverride.authenticationInfo( + authKeys.first.userId, + {}, + authId: '${authKeys.first.id}', + ), + ); + + await endpoints.status.signOut(sessionBuilder); + + authKeys = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId), + ); + expect(authKeys, isEmpty); + }, + ); + }, + ); + + withServerpod( + 'Given legacy sign-out option set to current device and user signed in to multiple devices', + (sessionBuilder, endpoints) { + late Session session; + late List authKeys; + + setUp(() async { + session = sessionBuilder.build(); + AuthConfig.set(AuthConfig( + legacyUserSignOutBehavior: SignOutBehavior.currentDevice, + )); + + authKeys = await _signInUserToMultipleDevices(session, userId); + }); + + test( + 'when signing out user then only the current device is signed out', + () async { + sessionBuilder = sessionBuilder.copyWith( + authentication: AuthenticationOverride.authenticationInfo( + authKeys.first.userId, + {}, + authId: '${authKeys.first.id}', + ), + ); + + await endpoints.status.signOut(sessionBuilder); + + authKeys = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId), + ); + expect(authKeys, hasLength(1)); + }, + ); + }, + ); +} + +Future> _signInUserToMultipleDevices( + Session session, + int userId, +) async { + return Future.wait([ + UserAuthentication.signInUser(session, userId, 'email'), + UserAuthentication.signInUser(session, userId, 'google'), + ]); +} diff --git a/modules/serverpod_auth/serverpod_auth_shared_flutter/lib/src/session_manager.dart b/modules/serverpod_auth/serverpod_auth_shared_flutter/lib/src/session_manager.dart index be06163523..efeea9d057 100644 --- a/modules/serverpod_auth/serverpod_auth_shared_flutter/lib/src/session_manager.dart +++ b/modules/serverpod_auth/serverpod_auth_shared_flutter/lib/src/session_manager.dart @@ -83,12 +83,20 @@ class SessionManager with ChangeNotifier { return refreshSession(); } - /// Signs the user out from all connected devices. Returns true if successful. - Future signOut() async { + /// Signs the user out from their devices. + /// If [allDevices] is true, signs out from all devices; otherwise, signs out from the current device only. + /// Returns true if the sign-out is successful. + Future _signOut({ + required bool allDevices, + }) async { if (!isSignedIn) return true; try { - await caller.status.signOut(); + if (allDevices) { + await caller.status.signOutAllDevices(); + } else { + await caller.status.signOutDevice(); + } await caller.client.updateStreamingConnectionAuthenticationKey(null); _signedInUser = null; @@ -102,6 +110,27 @@ class SessionManager with ChangeNotifier { } } + /// **[Deprecated]** Signs the user out from all connected devices. + /// Use `signOutDevice` for the current device or `signOutAllDevices` for all devices. + /// Returns true if successful. + @Deprecated( + 'Use signOutDevice for the current device or signOutAllDevices for all devices. This method will be removed in future releases.') + Future signOut() async { + return _signOut(allDevices: true); + } + + /// Signs the user out from all connected devices. + /// Returns true if successful. + Future signOutAllDevices() async { + return _signOut(allDevices: true); + } + + /// Signs the user out from the current device. + /// Returns true if successful. + Future signOutDevice() async { + return _signOut(allDevices: false); + } + /// Verify the current sign in status with the server and update the UserInfo. /// Returns true if successful. Future refreshSession() async { diff --git a/templates/pubspecs/modules/serverpod_auth/serverpod_auth_server/pubspec.yaml b/templates/pubspecs/modules/serverpod_auth/serverpod_auth_server/pubspec.yaml index ca71d9b39a..4973d26769 100644 --- a/templates/pubspecs/modules/serverpod_auth/serverpod_auth_server/pubspec.yaml +++ b/templates/pubspecs/modules/serverpod_auth/serverpod_auth_server/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: dev_dependencies: serverpod_lints: SERVERPOD_VERSION test: ^1.24.2 + serverpod_test: 2.1.5 dependency_overrides: serverpod: @@ -36,6 +37,8 @@ dependency_overrides: path: ../../../packages/serverpod_lints serverpod_shared: path: ../../../packages/serverpod_shared + serverpod_test: + path: ../../../packages/serverpod_test false_secrets: - test/firebase/firebase_auth_mock.dart diff --git a/templates/pubspecs/tests/serverpod_test_flutter/pubspec.yaml b/templates/pubspecs/tests/serverpod_test_flutter/pubspec.yaml new file mode 100644 index 0000000000..531cb689bc --- /dev/null +++ b/templates/pubspecs/tests/serverpod_test_flutter/pubspec.yaml @@ -0,0 +1,33 @@ +# TEMPLATE + +publish_to: none + +name: serverpod_test_flutter +version: 1.0.0 +description: Part of tests for Serverpod. +repository: https://github.com/serverpod/serverpod + +environment: + sdk: DART_VERSION + flutter: FLUTTER_VERSION + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.5 + +dev_dependencies: + flutter_test: + sdk: flutter + test: ^1.24.2 + serverpod_test_client: + path: ../serverpod_test_client + serverpod_auth_shared_flutter: + path: ../../modules/serverpod_auth/serverpod_auth_shared_flutter + +dependency_overrides: + serverpod_auth_client: + path: ../../modules/serverpod_auth/serverpod_auth_client + +flutter: + uses-material-design: true diff --git a/tests/docker/tests_flutter/Dockerfile-server b/tests/docker/tests_flutter/Dockerfile-server new file mode 100644 index 0000000000..d6dc8a3082 --- /dev/null +++ b/tests/docker/tests_flutter/Dockerfile-server @@ -0,0 +1,18 @@ +# Specify the Dart SDK base image version +FROM dart:3.3.0 AS build + +# Install psql client. +RUN apt-get update && apt-get install -y postgresql-client + +# Set the working directory +WORKDIR /app + +# Copy the whole serverpod repo into the container. +COPY . . + +# Install dependencies for test server. +WORKDIR tests/serverpod_test_server +RUN dart pub get + +# Setup database tables and start the server. +CMD ["../docker/tests_flutter/start-server.sh"] diff --git a/tests/docker/tests_flutter/Dockerfile-tests b/tests/docker/tests_flutter/Dockerfile-tests new file mode 100644 index 0000000000..1507e9e6fc --- /dev/null +++ b/tests/docker/tests_flutter/Dockerfile-tests @@ -0,0 +1,27 @@ +# Specify the Dart SDK base image version +FROM dart:3.3.0 AS build + +# Install xz +RUN apt-get update && apt-get install -y xz-utils + +# Install flutter +ENV FLUTTER_HOME=/opt/flutter +ENV FLUTTER_URL="https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.19.0-stable.tar.xz" +ENV PATH="$PATH:$FLUTTER_HOME/bin" + +RUN curl -o flutter.tar.xz $FLUTTER_URL \ + && mkdir -p $FLUTTER_HOME \ + && tar xf flutter.tar.xz -C /opt \ + && rm flutter.tar.xz + +# Mark flutter directory as safe +RUN git config --global --add safe.directory /opt/flutter + +# Set the working directory +WORKDIR /app + +# Copy the whole serverpod repo into the container. +COPY . . + +# Setup database tables and start the server. +CMD ["tests/docker/tests_flutter/run-tests.sh"] diff --git a/tests/docker/tests_flutter/docker-compose.yml b/tests/docker/tests_flutter/docker-compose.yml new file mode 100644 index 0000000000..98a516a9f7 --- /dev/null +++ b/tests/docker/tests_flutter/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.7' + +services: + serverpod_test_server: + build: + context: ../../.. + dockerfile: tests/docker/tests_flutter/Dockerfile-server + depends_on: + - 'postgres' + - 'redis' + tests: + build: + context: ../../.. + dockerfile: tests/docker/tests_flutter/Dockerfile-tests + depends_on: + - 'serverpod_test_server' + postgres: + image: postgres:16.3 + environment: + POSTGRES_USER: postgres + POSTGRES_DB: serverpod_test + POSTGRES_PASSWORD: password + redis: + image: redis:6.2.6 + command: redis-server --requirepass password + environment: + - REDIS_REPLICATION_MODE=master diff --git a/tests/docker/tests_flutter/reset_db.pgsql b/tests/docker/tests_flutter/reset_db.pgsql new file mode 100644 index 0000000000..eb786ea7a1 --- /dev/null +++ b/tests/docker/tests_flutter/reset_db.pgsql @@ -0,0 +1,4 @@ +DROP SCHEMA public CASCADE; +CREATE SCHEMA public; +GRANT ALL ON SCHEMA public TO postgres; +GRANT ALL ON SCHEMA public TO public; diff --git a/tests/docker/tests_flutter/run-tests.sh b/tests/docker/tests_flutter/run-tests.sh new file mode 100755 index 0000000000..6ff0ab1be7 --- /dev/null +++ b/tests/docker/tests_flutter/run-tests.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# Makes script exit on first non-zero error code +set -e + +# Install the serverpod command +echo "### Installing CLI tools" + +export PATH="$PATH":"$HOME/.pub-cache/bin" + +export SERVERPOD_HOME=$(pwd) +echo "### Serverpod home: $SERVERPOD_HOME" + +cd tools/serverpod_cli +dart pub global activate -s path . +cd ../.. + +# Wait for the server to be up (timeout after 60 seconds) +echo "### Wait for test server" +/app/tests/docker/tests_flutter/wait-for-it.sh serverpod_test_server:8080 -t 60 -- echo "### Server main is UP" +/app/tests/docker/tests_flutter/wait-for-it.sh serverpod_test_server:8081 -t 60 -- echo "### Server insights is UP" +echo "" + +# We are all set to start the server +echo "### Running integration tests" +cd tests/serverpod_test_flutter +flutter pub get +flutter test test_integration --concurrency=1 \ No newline at end of file diff --git a/tests/docker/tests_flutter/start-server.sh b/tests/docker/tests_flutter/start-server.sh new file mode 100755 index 0000000000..850700518c --- /dev/null +++ b/tests/docker/tests_flutter/start-server.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Wait for database to be up (timeout after 60 seconds) +echo "### Wait for Postgres" +/app/tests/docker/tests_flutter/wait-for-it.sh postgres:5432 -t 60 -- echo "### Postgres is UP" +/app/tests/docker/tests_flutter/wait-for-it.sh redis:6379 -t 60 -- echo "### Redis is UP" +echo "" + +# Reset the database +echo "### Resetting database" +env PGPASSWORD="password" psql -h postgres -U postgres -d serverpod_test -f /app/tests/docker/tests_flutter/reset_db.pgsql + +# We are all set to start the server +echo "### Starting test server" +pwd +dart bin/main.dart -m production --apply-migrations diff --git a/tests/docker/tests_flutter/wait-for-it.sh b/tests/docker/tests_flutter/wait-for-it.sh new file mode 100755 index 0000000000..d990e0d364 --- /dev/null +++ b/tests/docker/tests_flutter/wait-for-it.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi diff --git a/tests/serverpod_test_flutter/.gitignore b/tests/serverpod_test_flutter/.gitignore new file mode 100644 index 0000000000..29a3a5017f --- /dev/null +++ b/tests/serverpod_test_flutter/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/tests/serverpod_test_flutter/.metadata b/tests/serverpod_test_flutter/.metadata new file mode 100644 index 0000000000..bd14d1f451 --- /dev/null +++ b/tests/serverpod_test_flutter/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "5874a72aa4c779a02553007c47dacbefba2374dc" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 5874a72aa4c779a02553007c47dacbefba2374dc + base_revision: 5874a72aa4c779a02553007c47dacbefba2374dc + - platform: web + create_revision: 5874a72aa4c779a02553007c47dacbefba2374dc + base_revision: 5874a72aa4c779a02553007c47dacbefba2374dc + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/tests/serverpod_test_flutter/CHANGELOG.md b/tests/serverpod_test_flutter/CHANGELOG.md new file mode 100644 index 0000000000..0cdab22bb5 --- /dev/null +++ b/tests/serverpod_test_flutter/CHANGELOG.md @@ -0,0 +1,543 @@ +## 2.1.5 + - feat: EXPERIMENTAL. Adds testing framework. [docs](https://docs.serverpod.dev/next/concepts/testing/get-started) + - fix: Correctly handles method and endpoint streams for modules. + - fix: Correctly handles errors in method streams. + +## 2.1.4 + - feat: Adds detailed reporting for schema mismatches when checking database consistency. + - fix: Takes current transaction into account for include queries. + - fix: Loads passwords from env variables even if the `passwords.yaml` file doesn't exist. + - fix: Corrects type mismatch in `onTimeout` callback for cancelled subscriptions in `MethodStreamManager.closeAllStreams` method. + - fix: Correctly returns HTTP 400 error code if parameters passed to Serverpod are incorrect. + +## 2.1.3 + - fix: Includes Dockerfile for Serverpod Mini projects. + +## 2.1.2 + - fix: Supports updating full user name in auth module. + - fix: Adds missing transaction parameter in `deleteWhere` query. + - fix: Correctly preserves non-persisted fields during database insert and update operations. + - fix: Allows event listeners to remove themselves inside their handler. + - fix: Correctly checks settings before letting a user change name or image in auth module. + +## 2.1.1 + - fix: Posts revoked authentication events locally if Redis is disabled. + - fix: Uses `dynamic` type for `fromJson` parameter in custom class serialization. + +## 2.1.0 + - feat: Adds DevTools extension. + - feat: Adds support for `Stream` as parameters and return type in endpoint methods. + - feat: Adds stream subscriptions to message central. + - feat: Adds support for `willClose` listener on `Session`. + - feat: Adds support for default values in model files (types supported are `String`, `int`, `double`, `bool`, `DateTime`, `UuidValue`, `Duration`, enums) + - feat: Adds support for WASM compiled web apps. + - feat: Endpoint methods with `@Deprecated` annotation are now also annotated in the client. + - feat: Allows custom password hash generator in `AuthConfig`. + - feat: Allows rewrite rule in root path in static web directories. + - feat: Improves error handling in `SignInWithGoogle` by rethrowing exceptions. + - feat: Adds support for nullable types in `encodeWithType` and `decodeWithType`. + - feat: Adds `Uuid` identifier to sessions. + - feat: Supports configuration through environment variables instead of yaml. + - feat: Models can now be created without fields. + - feat: Adds ability to register custom environment variables to loaded as passwords. + - feat: Adds ability to modify `maxFileSize` and expiration time for GCP and AWS buckets. + - feat: Moves the auth key from the body of the request to the HTTP header in endpoint methods. + - feat: When sending a HTTP 400 Bad Request error message to the client, an error message may now be included in the client side exception. + - fix: Allows Serverpod defined models to be encoded and decoded with type. + - fix: Allows AWS deployments to update Dart version. + - fix: Fixes top error handling on server's request handler to ensure proper error boundary. + - fix: Fixes `copyWith` method for nested `List` and `Map` in models. + - fix: Fixes Dart version and other issues in AWS deployment templates. + - fix: Improved error message if there are missing tables. + - fix: Better error message if an error occurs when parsing the config files in CLI. + - fix: Adds validation of custom class names to look for potential collisions. + - fix: Only considers positional `Session` parameter when validating endpoint method. + - fix: Updates example documentation. + - fix: Before a session is closed, all logging is now awaited. + - fix: Adds new `WebCallSession` for Relic. + - fix: Correctly verifies `iss` value for all possible domains in Sign in with Google. + - fix: Add `methodName` and `endpointName` to base session class. + - fix: Handles malformed web server URI parameters more gracefully. + - fix: Uses `text` as `KeyboardType` for validation code in `SignInWithEmailDialog`. + - fix: Correctly orders logs in Insights. + - fix: Correctly strips data in serialization of `List` and `Map`. + - fix: Starts database pool manager on Serverpod instance creation. + - fix: Adds mechanism for awaiting pending future calls on shutdown. + - fix: Improvements to websocket lifecycle. + - fix: Registers cloud storage endpoint for any `storageId` with `db` storage. + - fix: Adds logging for when when uploads to buckets fail. + - fix: Removes redundant and non-prefixed `serverpod_serialization` import. + - fix: Sets the default authentication handler even when the database is disabled. + - chore: Updates dependencies. + +## 2.0.2 +- fix: Conditionally imports `HttpStatus` to improve compatibility. +- fix: Improve `encodeForProtocol` method for `List` and `Map` input object types. + +## 2.0.1 +- fix: Writes websocket errors to stderr. +- fix: Adds missing web socket connection notification on stream closed. +- fix: Sign in with Email dialog can toggle visibility of passwords. +- fix: Allows usage of user related Google API calls in `onUserCreated` callback. +- fix: Disposes streaming connection listener when disposing handler. +- fix: Only notifies listeners when streaming connection status changes. +- fix: Adds ready check for websocket channel. +- fix: Handles bad websocket upgrade requests. +- fix: Makes sign in buttons customizable. +- fix: Exposes getter for `Session` `authenticationKey`. +- fix: `postMessage` in messages now returns `true` if successful. +- fix: Improved Firebase login widget. +- fix: Adds support for inserting models with only an `id` field. +- fix: Throws exception if required fields are missing when parsing config files. +- fix: Adds explicit exception types for client side exceptions. +- fix: Correctly sets offset and length when encoding `ByteData` for database. +- fix: Removes endpoint to validate validation code. +- fix: Replaces asserts in auth module with throws and logs. +- fix: Changes default values in auth config. +- fix: Removes password reset verification code on usage attempt. +- fix: Stops web server when shutdown method is called. +- chore: Removes dependency to unsupported `firebase_admin` package. +- chore: Bumps minimum Dart version to 3.2.0. +- chore: Updates dependencies. + +## 2.0.0 +- fix: BREAKING. Database delete methods now return removed objects. +- fix: BREAKING. Removes automatic redirect from Relic. +- fix: BREAKING. Removes `SerializationManager` as a parameter from `fromJson` factory constructor. +- fix: BREAKING. Remove allToJson method. +- fix: BREAKING. Makes user name nullable in `UserInfo`. +- fix: BREAKING. Removes deprecated methods. +- fix: BREAKING. Introduces `DatabaseException`. +- fix: BREAKING. Introduces new types for database result sets. +- fix: BREAKING. Updates transaction interface in database. +- fix: BREAKING. Changes `SerializableEntity` mixin into `SerializableModel` interface. +- fix: BREAKING. Removes support for implicit string to expression conversion. +- fix: BREAKING. Marks deprecated yaml keywords as `isRemoved`. +- fix: BREAKING. Move authentication implementaqtions from core to auth module. +- fix: BREAKING. Removes `customConstructor` map from protocol class. +- chore: BREAKING. Updates Postgres library to new major version. +- feat: Adds parameter arguments to unsafe database queries. +- feat: Adds `upgrade` command to Serverpod CLI. +- feat: Introduces `CacheMissHandler` to improve cache API. +- feat: Serverpod mini. Allows running Serverpod without the database. +- feat: Makes email verification code length customizable. +- feat: Adds client entitlements to MacOS after creating Flutter project. +- fix: Improves server only field validation. +- fix: Retrieves and removes future call entries in a single operation. +- fix: toJson now includes all fields of Serverpod Models. +- fix: Maps Dart `int` to `bigint` in database. +- fix: Generates thumbnails in isolates for auth and chat module. +- fix: Improved logging in CLI. +- fix: Changes root file name in modules to follow Dart standards. +- fix: Removes useless stack trace print from database connection check. +- fix: Uses user scopes from `UserInfo` when authenticating in all providers. +- fix: Prevents silencing deserialization exceptions for unmatched class types. +- fix: Removes deprecated `generated` folder from Serverpod's upgrade template. +- fix: Endpoint requests can now respond with 401 or 403 on failed authentication. +- fix: Gives error when enpoint classes have naming conflicts. +- fix: Run `_storeSharedPrefs` in `logOut` method to preserve state. +- fix: Prints streaming message handler exceptions to console. +- chore: Bumps minimum required Dart version to 3.1. +- docs: Corrects spelling mistakes. +- docs: Improved documentation for chat module. + +## 1.2.7 +- fix: Spelling fix in UserAuthentication. +- fix: Prevents crash when web or template directory is missing (webserver). +- fix: Removes server only fields from client protocol deserialization. +- fix: Improved error messages in email authentication. +- fix: Minor log fixes. +- fix: Prevents generating empty endpoints variable when no endpoints are defined. +- fix: Adds Docker support for x86 architectures. +- fix: Adds timestamps to `generate --watch` command. +- chore: Updates dependencies. + +## 1.2.6 +- feat: Adds missing callbacks when sending chat messages in chat module. +- fix: Updates password hash algorithm for email authentication. [Security Advisories](https://github.com/serverpod/serverpod/security/advisories) +- fix: Improves client certificate security. [Security Advisories](https://github.com/serverpod/serverpod/security/advisories) +- fix: Fixes issue when passing empty set in `inSet` and `notInSet`. +- fix: Fixes issue with incorrect line breaks in CLI. + +## 1.2.5 +- fix: Custom classes respect nullable configuration. + +## 1.2.4 +- fix: Sets the correct output path for generated files on Windows. +- fix: Prevents VS Code extension from crashing on startup. +- fix: Marks file handling database methods as deprecated. +- fix: Correctly handles transaction parameters for delete method. +- fix: Correctly resolves and validates registered custom classes used as types in model fields. + +## 1.2.3 +- fix: Correctly cleans up health check manager when shutting down server. +- fix: Supports projects without a config generator file in CLI. +- fix: Adds additional requirements to Insights setup. +- fix: Removes unnecessary database connection creation in pool manager. +- fix: CLI gives error if non-string value is used as parent keyword. + +## 1.2.2 +- fix: Makes it possible to create modules from templates in developer mode. +- fix: Correctly marks nested enum types in the analyzer. +- fix: Adds support for all Serverpod's supported types as keys in Maps. +- fix: Restrict fields with scopes other than all to be nullable. +- fix: Uses pubspec override instead of direct paths (to improve score on pub.dev). +- fix: Less restrictive enum naming rules. +- fix: Pins Dart and Busybox docker image versions (only for new projects). +- fix: Deterministically truncate list aliases in database relations. +- fix: Enables server to start without migrated database. +- fix: Adds missing deprecation messages. +- fix: Adds placeholder for old postgres file, to aid users who are following old tutorials. +- fix: Resolves internal relation pointers in class representations. + +## 1.2.1 +- fix: Removes old generated folder from Dockerfile. +- fix: Prevents database analyzer from crashing when missing table. +- fix: Fixes issue with DevTools extension not being bundled with the `serverpod` package. +- fix: Ignores all null fields in JSON map serialization. +- fix: Improved error message if port is in use when starting server. +- chore: Bumps `vm_service` version to support latest version. + +## 1.2.0 +This is a summary of the new features in version 1.2.0. For the full list, please refer to the [commits](https://github.com/serverpod/serverpod/commits/main/) on Github. Instructions for updating from 1.1 of Serverpod is available in our documentation [here](https://docs.serverpod.dev/upgrading/upgrade-to-one-point-two). + +### Main new features and fixes +- feat: Adds official support for Windows. +- feat: Adds Visual Studio Code extension. +- feat: Syntax highlighting in model files. +- feat: Adds LSP server for analyzing model files. +- feat: CLI automatically detects modules without the need to modify the generator file. +- feat: Validates project names on `serverpod create`. +- feat: Validates Serverpod packages and CLI version in `serverpod generate`. +- feat: Prompts user to update Serverpod when running an old version of the CLI. +- feat: Improves exit codes for CLI. +- feat: Improvements to output from CLI, including different formats for different platforms and run-modes. +- feat: Progress animations in CLI. +- feat: Uses CommandRunner for CLI. +- feat: Adds global `--verbose` and `--quiet` flags to control log level. +- feat: Developer version of `serverpod create` now creates a project referring to the local version of Serverpod. +- feat: Adds `copyWith` methods on all generated model files. +- feat: Makes it possible to call endpoint methods by specifying the method name in the path. +- feat: Makes return headers configurable for API and OPTION HTTP calls. +- feat: Adds `fromYaml` constructor to `ServerpodConfig`. +- feat: Adds reference to all modules in config. +- feat: Makes HTTP timeout configurable. +- feat: Improves compatibility for `serverpod create` by not running Docker through tooling. +- fix: Makes endpoint classes public to enable Dart doc. +- fix: Serializable exceptions now work with modules. +- fix: Handles invalid return types when parsing endpoint methods. +- fix: Fixes localhost on Android emulator. +- fix: Use explicit version for all Serverpod packages. +- fix: Uses git version of CLI in local tests. +- fix: Fixes typos in `serverpod create` start instructions. +- fix: Makes include class fields private. +- fix: Adds flag to disable analytics reporting. +- fix: Correctly resets error message state when and endpoint call was successful in template project. +- fix: Closes session when protocol exception is thrown. +- fix: Allows deeply nested `Map` and `List` in model files. +- docs: Many improvements to API documentation. +- chore: Updates to latest version of Flutter. +- chore: Updates dependencies. +- chore: Fixes deprecated methods. +- chore: Makes Dart & Flutter version requirements consistent across packages. +- chore: Adds serverpod_lints package. +- ci: Now runs tests on multiple Flutter versions. +- ci: Adds 2000 new tests. +- ci: Unit tests are now running on Windows. + +### Database ORM +- feat: Adds support for database migrations. +- feat: Adds support for database repair migrations. +- feat: Adds support for database relations. +- feat: Support `IN`, `NOT IN`, `BETWEEN` and `NOT BETWEEN` query operations. +- feat: Separates `Column` from `Expression` and harmonizes operations. +- feat: Adds scoped database operations on generated models. +- feat: Adds batch `insert`, `update`, and `delete` database operations. +- feat: Exposes mapped results database query for public use. +- feat: Adds `notLike` and `notILike` on database `String` expressions. +- feat: Adds column selection to generated update method. +- fix: Adds helpful error message if wrong table is used for `where` or `orderBy` expression. +- fix: Change signature of `orderBy` and `orderByList` to callback taking a typed table. +- fix: Removes old Postgres generator (replaced by migrations). + +### Model files (.spy.yaml) and code generation +- feat: Dual pass parsing when validating model files. +- feat: Validates field datatypes when running `serverpod generate`. +- feat: Adds deprecation warnings to old model file keywords. +- feat: Adds severity levels to reported errors in analyzer. +- feat: Adds ability to toggle implicit key in stringified nodes. +- feat: Reports severity level of errors. +- feat: Adds `scope` and `persist` keywords to models. +- feat: Adds `onDelete` and `onUpdate` bindings. +- feat: Introduces reserved keywords in protocols. +- feat: Adds serialization `byName` option for enums. +- feat: Allow `.spy` file extension on model files (default is now `.spy.yaml`). +- feat: Now loads model files from `src/lib/models` directory (old `protocol` directory is still supported for backward compatibility). +- feat: Adds type validation to model files. +- fix: Allows multiple uppercase characters in model class names. +- fix: Protocol entities only allowed to be one type. +- fix: Better error messages for `fields` in model files. +- fix: Enforce index types to be a valid Postgres index type. +- fix: Require all serialized enum values to be unique. +- fix: Enforce that the `id` field isn't used for models that have a table defined. +- fix: Enforce that `parent` keyword is only used if a model has an associated table. +- fix: Report an error if the referenced parent table does not exists. +- fix: Report an error if the table name in a model is not globally unique. +- fix: Report an error if an index name is not globally unique. +- fix: Report an error if a field is referenced twice in the same index. +- fix: Allows complex types to be nullable. +- fix: Parse the source location for all comma separated values in a field string. +- fix: Restrict class names to now allow standard datatypes. +- fix: Add automatic deprecated reporting of keys in analyzer. +- fix: Set exit code to non-zero if generator finds issues. +- fix: Correctly validate deeply nested datatypes in protocols. +- fix: Enum value restrictions matches default linting in Dart. +- fix: Less restrictive naming of model class names. +- fix: Avoid generating code from broken protocol files. +- fix: Deprecate `database` and `api` keywords. +- fix: Stop generator from getting stuck on circular dependencies. +- fix: Handle invalid YAML errors and report them. +- fix: Only report duplicated and invalid negations once. +- fix: Adds deep check of `DateTime` and `Uint8List` during deserialization. +- fix: Deserialization of `DateTime` handles `null` explicitly. +- fix: Only return valid entries from analyzer. +- fix: Reintroduces generation of `protocol.yaml`. +- fix: Use version command to check if a command exists. +- fix: Prevents `generate --watch` from crashing. +- fix: Prevents analyzer from crashing because of invalid Dart syntax. +- fix: Prevents analyzer from crashing when an unsupported type is used. +- fix: Avoid serializing null `Map` values. +- fix: Restrict length of user defined Postgres identifier names. + +### Insights +- feat: Insight endpoint methods for running queries and fetching full database configuration. +- feat: Adds module name and Dart class names to table definitions in Insights protocol. +- feat: Support for filtering bulk data fetched from Serverpod Insights. +- feat: Adds API for accessing files local to the server. +- fix: Include installed modules in all database definitions. + +### Auth module +- feat: Improves auth example. +- feat: Adds Sign in with Apple button. +- feat: Adds Google Sign in on the web. +- feat: Allows min and max password lengths to be configured in auth module. +- feat: Allows label and icon to be customized for Sign in with Email button in auth. +- fix: Removes dead code in auth module. +- fix: Adds error message if email is already in use in auth. +- fix: Properly close barrier when sign in is complete in auth. +- fix: Corrects typo in sign in button. +- fix: Require consent in order to generate refresh token for Google Signin. +- fix: Throw descriptive error if Google auth secret is not loaded on the server. +- fix: Typo in reset password example email. +- fix: Enforces user blocked status on login. +- fix: Allows Firebase phone auth and logs auth errors. + +### File storage +- feat: Adds bulk file URL lookup method for file storage. + +### Chat module +- fix: Adds missing return statement to require authentication. + + +## 1.1.0 +- feat: Lightweight run mode and support for serverless platforms. +- feat: Support for Google Cloud Platform deployments, including Terraform module. +- feat: Adds serializable exceptions that can be passed from the server to the client. +- feat: Adds `serverOnly` option to yaml-files, which is set to true will prevent the code to be generated for the client. +- feat: Support for `UUID` in serialization. +- feat: New supported static file types in Relic. +- feat: Allows endpoints in sub directories. +- feat: Support for GCP Cloud Storage. +- feat: Support for connecting to Postgres through a UNIX socket. +- feat: Adds database maintenance methods to Insights APIs (still experimental and API may change). +- docs: Improved documentation. +- fix: Better output on startup to aid in debugging connectivity issues. +- fix: Prevents self referencing table to cause `serverpod generate` to hang. +- fix: Adds email from Firebase to UserInfo in auth module. +- fix: Don't print stack trace when Google signin disconnect fails. +- fix: Return bool from `SessionManager.initialize()` to indicate if server was reached. +- fix: Better recovery when parsing yaml-files. +- chore: Migrates Firebase to new Flutter APIs. +- chore: Updates dependencies. +- chore: Refactors CLI tooling. + +## 1.0.1 +- Fixes import of generics in subdirectories. +- Generated enums now respect their subdirectories. +- Masks out passwords in email debug logging. +- Replaces deprecated `docker-compose` with `docker compose` + +## 1.0.0 +- First stable release! :D +- Fixes incorrectly set database index on health metrics. + +## 0.9.22 +- Adds support for snake case in fields. +- Adds support for Duration data types in serialized objects. +- Correctly sets CORS headers on failed calls. +- Correctly imports generated files in subdirectories. +- Allows documentation in yaml files. +- Adds documentation for all generated code. +- __Breaking changes__: Optimizes health metric data. Requires updates to two database tables. Detailed migration instructions here: [https://github.com/serverpod/serverpod/discussions/567](https://github.com/serverpod/serverpod/discussions/567) + +## 0.9.21 +- Supports sub directories for protocol class files. +- Updates dependencies for auth module. +- Nicer default web page for new projects. +- Adds authentication example. +- Correctly inserts ByteData into the database. +- Much improved documentation for authentication. +- __Breaking changes__: The `active` and `suspendedUntil` fields of `UserInfo` in the auth module has been removed. These fields need to be removed from the database for authentication to work. + +## 0.9.20 +- New serialization layer thanks to the extensive work of [Maximilian Fischer](https://github.com/fischerscode). This adds compatibility with custom serialization, such as [Freezed](https://pub.dev/packages/freezed). It also adds support for nested `Map`s and `List`s. +- Updates examples. +- More extensive test coverage. +- Much improved documentation. +- __Breaking changes__: This version updates the Serverpod protocol, which is now much more streamlined ahead of the 1.0 release. Unfortunately, it makes apps built with earlier versions incompatible with the latest version of Serverpod. More detailed migration instructions here: [https://github.com/serverpod/serverpod/discussions/401](https://github.com/serverpod/serverpod/discussions/401) + +## 0.9.19 +- Adds support for storing and reading binary ByteData to/from the database. + +## 0.9.18 +- Adds chat module to published packages. + +## 0.9.17 +- Reliability fix for FlutterConnectivityMonitor on web platform. + +## 0.9.16 +- Changes default log level to `info`. +- Fixes issue with `serverpod create` command and updates template files with correct Flutter dependencies. + +## 0.9.15 +- Correctly sets 404 return code if no route is matched in Relic web server. +- Templates are updated to use latest version of flutter_lints. +- Adds connectivity monitor for streaming connections, which improves their reliability. + +## 0.9.14 +- Official support for Linux. +- Improved support for Windows. +- Adds tests for command line tools. + +## 0.9.13 +- Updates download path for template files. + +## 0.9.12 +- Adds `connecting` state to streaming connections. +- Refactors streaming connection method names to be more consistent (backwards compatible with deprecations). +- Adds `StreamingConnectionHandler` to automatically reconnect to the server when streaming connection is lost. +- Automatically upgrades streaming connections when a user is signed it (`serverpod_auth` module). +- Better error handling when providing invalid commands to the CLI. +- Moves tests to `serverpod_test_server`. +- Fixes error on `serverpod create --template module ...` +- Hides errors produced by health checks. + +## 0.9.11 +- Adds support for Map structures in serialized objects. +- Adds support for passing maps and lists as parameters to endpoint methods. +- Much improved error checks in code generation. +- Adds continuous code generation with `serverpod generate --watch`. +- Removes the `serverpod run` command in favor for continuous generation. +- Updates dependencies to latest versions. +- Cleans up `serverpod help` command. + +## 0.9.10 +- Brings example code up-to-date with latest changes in Serverpod +- Improved security for email sign in (limits sign in attempts based on a time period). +- Dart docs are now copied to generated code, making it easier to document APIs. +- Fixes issue with logging of queries in streaming sessions. +- Adds support for Sign in with Firebase. +- __Breaking changes__: Adds a new table for email sign in. Migration instructions here: [https://github.com/serverpod/serverpod/discussions/246](https://github.com/serverpod/serverpod/discussions/246) + +## 0.9.9 +- Improved Terraform scripts for AWS will use less resources. Most notably, only uses one load balancer which will fit within AWS free tier. +- Adds web server to Terraform scripts. +- Includes the Relic web server within the main Serverpod package. +- Much improved logging and health checks. +- Allows for monitoring of CPU and memory use. +- Many smaller bug fixes and improvements. +- __Breaking changes__: Updates config files and tables for logging. Migration instructions here: [https://github.com/serverpod/serverpod/discussions/190](https://github.com/serverpod/serverpod/discussions/190) + +## 0.9.8 +- Adds Terraform deployment scripts for AWS. Documentation here: [https://github.com/serverpod/serverpod/discussions/182](https://github.com/serverpod/serverpod/discussions/182) +- __Breaking change__: Updates structure of config files. Migration instructions here: [https://github.com/serverpod/serverpod/discussions/182](https://github.com/serverpod/serverpod/discussions/182) +- Moves Redis enabled option to config file and turns it off by default. +- `serverpod run` no longer manages the Docker containers as it caused an issue with restarting the server. + +## 0.9.7 +- `serverpod create` and `serverpod generate` is now working on Windows. Tested on a fresh install of Windows 10. + +## 0.9.6 +- Improved, but still experimental support for Windows. +- Fixes issue with error being thrown when internet connection is missing in CLI. +- Correctly ignores overridden methods of Endpoints in code generation. +- Makes using Redis optional. +- Much improved [documentation](https://docs.serverpod.dev). + +## 0.9.5 + +- Adds `serverpod run` command and improves `serverpod create`. +- Continuous generation through `severpod run`. +- Automatic restarts through `serverpod run`. + +## 0.9.4 + +- Updates to documentation. +- Makes it possible to cancel future calls. +- Improves accuracy in future calls. +- Saves/restores refresh tokens for Google sign in. + +## 0.9.3 + +- Updates to documentation. + +## 0.9.2 + +- Adds serverpod_auth module for authentication with email, Apple, and Google. + +## 0.9.1 + +- Fixes broken images in documentation. + +## 0.9.0 + +- Updates documentation and logos +- Ready for 0.9 release! + +## 0.8.12 + +- Updates default templates. + +## 0.8.11 + +- Improved ORM. +- Support for Docker. +- Chat module. +- Updated docs. + +## 0.8.10 + +- Support for static file directories in Relic. +- Adds logos (psd and pngs). +- Adds example project. +- Initial version of authentication module. +- Cloud storage support. +- Adds auth module + +## 0.8.6 + +- Adds documentation. +- Generates SQL files for creating database tables. + +## 0.8.5 + +- Fixes compilation in broken serverpod_cli package + +## 0.8.4 + +- Updates template files and fixes `serverpod create` command. +- Adds CHANGELOG.md + +## 0.8.3 + +- Initial working version. diff --git a/tests/serverpod_test_flutter/README.md b/tests/serverpod_test_flutter/README.md new file mode 100644 index 0000000000..e967859687 --- /dev/null +++ b/tests/serverpod_test_flutter/README.md @@ -0,0 +1,9 @@ +![Serverpod banner](https://github.com/serverpod/serverpod/raw/main/misc/images/github-header.webp) + +# Serverpod +This package is a core part of Serverpod. For documentation, visit: [https://docs.serverpod.dev](https://docs.serverpod.dev). + +## What is Serverpod? +Serverpod is an open-source, scalable app server, written in Dart for the Flutter community. Check it out! + +[Serverpod.dev](https://serverpod.dev) diff --git a/tests/serverpod_test_flutter/analysis_options.yaml b/tests/serverpod_test_flutter/analysis_options.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/serverpod_test_flutter/pubspec.yaml b/tests/serverpod_test_flutter/pubspec.yaml new file mode 100644 index 0000000000..1bb474687b --- /dev/null +++ b/tests/serverpod_test_flutter/pubspec.yaml @@ -0,0 +1,34 @@ +# This file is generated. Do not modify, instead edit the files in the templates/pubspecs directory. +# Mode: development + +publish_to: none + +name: serverpod_test_flutter +version: 1.0.0 +description: Part of tests for Serverpod. +repository: https://github.com/serverpod/serverpod + +environment: + sdk: '>=3.3.0 <4.0.0' + flutter: '>=3.19.0' + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.5 + +dev_dependencies: + flutter_test: + sdk: flutter + test: ^1.24.2 + serverpod_test_client: + path: ../serverpod_test_client + serverpod_auth_shared_flutter: + path: ../../modules/serverpod_auth/serverpod_auth_shared_flutter + +dependency_overrides: + serverpod_auth_client: + path: ../../modules/serverpod_auth/serverpod_auth_client + +flutter: + uses-material-design: true diff --git a/tests/serverpod_test_flutter/test_integration/auth_session_manager_signout_test.dart b/tests/serverpod_test_flutter/test_integration/auth_session_manager_signout_test.dart new file mode 100644 index 0000000000..14d286f109 --- /dev/null +++ b/tests/serverpod_test_flutter/test_integration/auth_session_manager_signout_test.dart @@ -0,0 +1,192 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:serverpod_test_client/serverpod_test_client.dart'; +import 'package:serverpod_auth_shared_flutter/serverpod_auth_shared_flutter.dart'; + +class MockStorage implements Storage { + final Map _values = {}; + @override + Future getInt(String key) async => _values[key]; + @override + Future getString(String key) async => _values[key]; + @override + Future remove(String key) async => _values.remove(key); + @override + Future setInt(String key, int value) async => _values[key] = value; + @override + Future setString(String key, String value) async => + _values[key] = value; +} + +void main() { + const serverUrl = 'http://serverpod_test_server:8080/'; + + group('Given two authenticated clients with SessionManagers', () { + late Client primaryClient; + late Client secondaryClient; + late SessionManager primarySessionManager; + late SessionManager secondarySessionManager; + + setUp(() async { + primaryClient = Client( + serverUrl, + authenticationKeyManager: FlutterAuthenticationKeyManager( + storage: MockStorage(), + ), + ); + primarySessionManager = SessionManager( + caller: primaryClient.modules.auth, + storage: MockStorage(), + ); + await primarySessionManager.initialize(); + + secondaryClient = Client( + serverUrl, + authenticationKeyManager: FlutterAuthenticationKeyManager( + storage: MockStorage(), + ), + ); + secondarySessionManager = SessionManager( + caller: secondaryClient.modules.auth, + storage: MockStorage(), + ); + await secondarySessionManager.initialize(); + + await _authenticateClientAndSessionManager( + primaryClient, + primarySessionManager, + ); + await _authenticateClientAndSessionManager( + secondaryClient, + secondarySessionManager, + ); + + assert( + primarySessionManager.isSignedIn, + 'Primary client failed to authenticate.', + ); + assert( + secondarySessionManager.isSignedIn, + 'Secondary client failed to authenticate.', + ); + assert( + await primaryClient.modules.auth.status.isSignedIn(), + 'Primary client is not signed in on the server.', + ); + assert( + await secondaryClient.modules.auth.status.isSignedIn(), + 'Secondary client is not signed in on the server.', + ); + }); + + tearDown(() async { + await primaryClient.modules.auth.status.signOutAllDevices(); + primaryClient.close(); + secondaryClient.close(); + }); + + group( + 'when calling the deprecated signOut method on the first SessionManager', + () { + setUp(() async { + // ignore: deprecated_member_use + bool result = await primarySessionManager.signOut(); + assert(result, 'Primary SessionManager failed to sign out.'); + }); + + test( + 'then the first client is signed out in SessionManager and on the server', + () async { + expect( + await primaryClient.modules.auth.status.isSignedIn(), + isFalse, + reason: + 'Primary client should be signed out but is still signed in on the server.', + ); + }); + + test( + 'then the second client is signed out in SessionManager and on the server', + () async { + expect( + await secondaryClient.modules.auth.status.isSignedIn(), + isFalse, + reason: + 'Secondary client should be signed out but is still signed in on the server.', + ); + }); + }); + + group('when calling signOutDevice on the first SessionManager', () { + setUp(() async { + bool result = await primarySessionManager.signOutDevice(); + assert(result, + 'Primary SessionManager failed to sign out from current device.'); + }); + + test( + 'then the first client is signed out in SessionManager and on the server', + () async { + expect( + await primaryClient.modules.auth.status.isSignedIn(), + isFalse, + reason: + 'Primary client should be signed out but is still signed in on the server.', + ); + }); + + test( + 'then the second client remains signed in in SessionManager and on the server', + () async { + expect( + await secondaryClient.modules.auth.status.isSignedIn(), + isTrue, + reason: 'Secondary client should remain signed in on the server.', + ); + }); + }); + + group('when calling signOutAllDevices on the first SessionManager', () { + setUp(() async { + bool result = await primarySessionManager.signOutAllDevices(); + assert(result, + 'Primary SessionManager failed to sign out from all devices.'); + }); + + test( + 'then the first client is signed out in SessionManager and on the server', + () async { + expect( + await primaryClient.modules.auth.status.isSignedIn(), + isFalse, + reason: + 'Primary client should be signed out but is still signed in on the server.', + ); + }); + + test( + 'then the second client is signed out in SessionManager and on the server', + () async { + expect( + await secondaryClient.modules.auth.status.isSignedIn(), + isFalse, + reason: + 'Secondary client should be signed out but is still signed in on the server.', + ); + }); + }); + }); +} + +Future _authenticateClientAndSessionManager( + Client client, SessionManager sessionManager) async { + var response = await client.authentication.authenticate( + 'test@foo.bar', + 'password', + ); + expect(response.success, isTrue, reason: 'Authentication failed for client.'); + await sessionManager.registerSignedInUser( + response.userInfo!, + response.keyId!, + response.key!, + ); +} diff --git a/tests/serverpod_test_server/test_e2e/signout/auth_status_signout_test.dart b/tests/serverpod_test_server/test_e2e/signout/auth_status_signout_test.dart new file mode 100644 index 0000000000..cbaefb6172 --- /dev/null +++ b/tests/serverpod_test_server/test_e2e/signout/auth_status_signout_test.dart @@ -0,0 +1,121 @@ +import 'package:serverpod_test_client/serverpod_test_client.dart'; +import 'package:serverpod_test_server/test_util/test_key_manager.dart'; +import 'package:serverpod_test_server/test_util/config.dart'; +import 'package:test/test.dart'; + +void main() { + group('Given two authenticated clients', () { + late Client primaryClient; + late Client secondaryClient; + + setUp(() async { + primaryClient = Client( + serverUrl, + authenticationKeyManager: TestAuthKeyManager(), + ); + secondaryClient = Client( + serverUrl, + authenticationKeyManager: TestAuthKeyManager(), + ); + + await _authenticateClient(primaryClient); + await _authenticateClient(secondaryClient); + + assert( + await primaryClient.modules.auth.status.isSignedIn(), + 'Primary client failed to authenticate', + ); + assert( + await secondaryClient.modules.auth.status.isSignedIn(), + 'Secondary client failed to authenticate', + ); + }); + + tearDown(() async { + await primaryClient.modules.auth.status.signOutAllDevices(); + primaryClient.close(); + secondaryClient.close(); + }); + + group('when calling the deprecated signOut method with first client', () { + setUp(() async { + // ignore: deprecated_member_use + await primaryClient.modules.auth.status.signOut(); + }); + + test('then first client is signed out', () async { + expect( + await primaryClient.modules.auth.status.isSignedIn(), + isFalse, + reason: 'Primary client was not signed out after signOut()', + ); + }); + + test('then second client is signed out', () async { + expect( + await secondaryClient.modules.auth.status.isSignedIn(), + isFalse, + reason: + 'Secondary client was not signed out after primary client signOut()', + ); + }); + }); + + group('when calling signOutCurrentDevice with first client', () { + setUp(() async { + await primaryClient.modules.auth.status.signOutDevice(); + }); + + test('then first client is signed out', () async { + expect( + await primaryClient.modules.auth.status.isSignedIn(), + isFalse, + reason: + 'Primary client was not signed out after signOutCurrentDevice()', + ); + }); + + test('then second client remains signed in', () async { + expect( + await secondaryClient.modules.auth.status.isSignedIn(), + isTrue, + reason: + 'Secondary client should remain signed in after primary client signOutCurrentDevice()', + ); + }); + }); + + group('when calling signOutAllDevices with first client', () { + setUp(() async { + await primaryClient.modules.auth.status.signOutAllDevices(); + }); + + test('then first client is signed out', () async { + expect( + await primaryClient.modules.auth.status.isSignedIn(), + isFalse, + reason: 'Primary client was not signed out after signOutAllDevices()', + ); + }); + + test('then second client is signed out', () async { + expect( + await secondaryClient.modules.auth.status.isSignedIn(), + isFalse, + reason: + 'Secondary client was not signed out after signOutAllDevices()', + ); + }); + }); + }); +} + +Future _authenticateClient(Client client) async { + var response = await client.authentication.authenticate( + 'test@foo.bar', + 'password', + ); + expect(response.success, isTrue, reason: 'Authentication failed for client'); + await client.authenticationKeyManager + ?.put('${response.keyId}:${response.key}'); +} diff --git a/tests/serverpod_test_server/test_integration/auth_module/auth_handler_test.dart b/tests/serverpod_test_server/test_integration/auth_module/auth_handler_test.dart new file mode 100644 index 0000000000..3583d0f4f2 --- /dev/null +++ b/tests/serverpod_test_server/test_integration/auth_module/auth_handler_test.dart @@ -0,0 +1,76 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:serverpod_auth_server/serverpod_auth_server.dart'; +import 'package:serverpod_test_server/test_util/test_serverpod.dart'; +import 'package:test/test.dart'; + +void main() async { + var session = await IntegrationTestServer().session(); + late AuthKey authKey; + + group('Given an authenticated user', () { + setUp(() async { + authKey = await UserAuthentication.signInUser( + session, + 1, + 'email', + updateSession: true, + ); + }); + + tearDown(() async { + await AuthKey.db.deleteWhere( + session, + where: (row) => Constant.bool(true), + ); + }); + + test('when authentication succeeds then authId is set to authKey id', + () async { + var result = await authenticationHandler( + session, + '${authKey.id}:${authKey.key}', + ); + + expect( + result?.authId, + equals('${authKey.id}'), + ); + }); + + test('when authentication fails then authId is null', () async { + var result = await authenticationHandler( + session, + '${authKey.id}:invalid-key', + ); + + expect( + result?.authId, + isNull, + ); + }); + + test('when authKey is not found then authentication fails', () async { + var result = await authenticationHandler( + session, + '9999:${authKey.key}', // Non-existing keyId + ); + + expect( + result?.authId, + isNull, + ); + }); + + test('when key format is invalid then authentication fails', () async { + var result = await authenticationHandler( + session, + 'invalid-format-key', // Invalid format + ); + + expect( + result?.authId, + isNull, + ); + }); + }); +} diff --git a/tests/serverpod_test_server/test_integration/auth_module/auth_signin_test.dart b/tests/serverpod_test_server/test_integration/auth_module/auth_signin_test.dart new file mode 100644 index 0000000000..dc64ec5ed5 --- /dev/null +++ b/tests/serverpod_test_server/test_integration/auth_module/auth_signin_test.dart @@ -0,0 +1,91 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:serverpod_auth_server/serverpod_auth_server.dart'; +import 'package:serverpod_test_server/test_util/test_serverpod.dart'; +import 'package:test/test.dart'; + +void main() async { + var session = await IntegrationTestServer().session(); + var userId = 1; + + group('Given an authenticated user', () { + tearDown(() async { + session.updateAuthenticated(null); + await AuthKey.db.deleteWhere( + session, + where: (row) => Constant.bool(true), + ); + }); + + test( + 'when updateSession is true then the session is updated with authentication info', + () async { + await UserAuthentication.signInUser( + session, + userId, + 'email', + updateSession: true, + ); + + var auth = await session.authenticated; + + expect( + auth, + isNotNull, + reason: + 'Expected session to be updated with user authentication info.', + ); + expect( + auth?.userId, + equals(userId), + reason: 'User ID in session should match the signed-in user ID.', + ); + }, + ); + + test( + 'when updateSession is false then the session is not updated with authentication info', + () async { + await UserAuthentication.signInUser( + session, + userId, + 'email', + updateSession: false, + ); + + var auth = await session.authenticated; + + expect( + auth, + isNull, + reason: + 'Expected session to not be updated with user authentication info.', + ); + }, + ); + + test( + 'when updateSession is not provided (default behavior) then the session is updated with authentication info', + () async { + await UserAuthentication.signInUser( + session, + userId, + 'email', + ); + + var auth = await session.authenticated; + + expect( + auth, + isNotNull, + reason: + 'Expected session to be updated with user authentication info by default.', + ); + expect( + auth?.userId, + equals(userId), + reason: 'User ID in session should match the signed-in user ID.', + ); + }, + ); + }); +} diff --git a/tests/serverpod_test_server/test_integration/auth_module/auth_signout_test.dart b/tests/serverpod_test_server/test_integration/auth_module/auth_signout_test.dart new file mode 100644 index 0000000000..2bc6b76672 --- /dev/null +++ b/tests/serverpod_test_server/test_integration/auth_module/auth_signout_test.dart @@ -0,0 +1,185 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:serverpod_auth_server/serverpod_auth_server.dart'; +import 'package:test/test.dart'; +import '../test_tools/serverpod_test_tools.dart'; + +void main() { + var userId1 = 1; + var userId2 = 2; + var invalidUserId = -1; + var invalidAuthKeyId = -1; + + withServerpod( + 'Given a user signed in to multiple devices', + (sessionBuilder, endpoints) { + late Session session; + + setUp(() async { + session = sessionBuilder.build(); + + await Future.wait([ + UserAuthentication.signInUser(session, userId1, 'email'), + UserAuthentication.signInUser(session, userId1, 'email'), + ]); + + var authKeys = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId1), + ); + assert(authKeys.length == 2, 'Expected 2 auth keys after signing in.'); + }); + + tearDown(() async { + await AuthKey.db.deleteWhere( + session, + where: (_) => Constant.bool(true), + ); + }); + + test( + 'when signing out then user is signed out of all devices', + () async { + await UserAuthentication.signOutUser(session, userId: userId1); + + var authKeys = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId1), + ); + expect(authKeys, isEmpty); + }, + ); + + test( + 'when revoking auth key then only the revoked auth key is deleted', + () async { + var authKeys = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId1), + ); + var secondAuthKey = authKeys.last; + + await UserAuthentication.revokeAuthKey( + session, + authKeyId: "${secondAuthKey.id!}", + ); + + authKeys = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId1), + ); + expect(authKeys, hasLength(1)); + }, + ); + + test( + 'when signing out with invalid userId then no keys are deleted', + () async { + await UserAuthentication.signOutUser(session, userId: invalidUserId); + + var authKeys = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId1), + ); + expect(authKeys, hasLength(2)); + }, + ); + + test( + 'when revoking with invalid authKeyId then no keys are deleted', + () async { + await UserAuthentication.revokeAuthKey( + session, + authKeyId: "$invalidAuthKeyId", + ); + + var authKeys = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId1), + ); + expect(authKeys, hasLength(2)); + }, + ); + }, + ); + + withServerpod( + 'Given two users signed in to multiple devices', + (sessionBuilder, endpoints) { + late Session session; + + setUp(() async { + session = sessionBuilder.build(); + + await Future.wait([ + UserAuthentication.signInUser(session, userId1, 'email'), + UserAuthentication.signInUser(session, userId1, 'email'), + UserAuthentication.signInUser(session, userId2, 'email'), + UserAuthentication.signInUser(session, userId2, 'email'), + ]); + + var authKeysUser1 = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId1), + ); + var authKeysUser2 = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId2), + ); + assert(authKeysUser1.length == 2, 'Expected 2 auth keys for user 1.'); + assert(authKeysUser2.length == 2, 'Expected 2 auth keys for user 2.'); + }); + + tearDown(() async { + await AuthKey.db.deleteWhere( + session, + where: (_) => Constant.bool(true), + ); + }); + + test( + 'when signing out user1 then user1 is signed out but user2 remains signed in', + () async { + await UserAuthentication.signOutUser(session, userId: userId1); + + var authKeysUser1 = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId1), + ); + var authKeysUser2 = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId2), + ); + expect(authKeysUser1, isEmpty); + expect(authKeysUser2, hasLength(2)); + }, + ); + + test( + 'when revoking auth key for user1 then user1 loses one authentication key but user2 remains unaffected', + () async { + var authKeysUser1 = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId1), + ); + var secondAuthKeyUser1 = authKeysUser1.last; + + await UserAuthentication.revokeAuthKey( + session, + authKeyId: "${secondAuthKeyUser1.id!}", + ); + + var authKeysUser1After = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId1), + ); + var authKeysUser2 = await AuthKey.db.find( + session, + where: (row) => row.userId.equals(userId2), + ); + expect(authKeysUser1After, hasLength(1)); + expect(authKeysUser2, hasLength(2)); + }, + ); + }, + ); +} diff --git a/util/pub_get_all b/util/pub_get_all index 1e0f57e10d..e7f99dc20d 100755 --- a/util/pub_get_all +++ b/util/pub_get_all @@ -67,6 +67,7 @@ declare -a flutter_paths=( "modules/serverpod_auth/serverpod_auth_email_flutter" "modules/serverpod_auth/serverpod_auth_firebase_flutter" "modules/serverpod_chat/serverpod_chat_flutter" + "tests/serverpod_test_flutter" ) # Upgrade Dart packages diff --git a/util/run_tests_analyze b/util/run_tests_analyze index c86219b7e3..5d74cf1887 100755 --- a/util/run_tests_analyze +++ b/util/run_tests_analyze @@ -36,6 +36,7 @@ declare -a projectPaths=( "tests/serverpod_test_server" "tests/serverpod_test_client" + "tests/serverpod_test_flutter" "tests/serverpod_test_module/serverpod_test_module_server" "tests/serverpod_test_module/serverpod_test_module_client" diff --git a/util/run_tests_flutter_integration b/util/run_tests_flutter_integration new file mode 100755 index 0000000000..19ebfe6824 --- /dev/null +++ b/util/run_tests_flutter_integration @@ -0,0 +1,14 @@ +#!/bin/bash + +if [ ! -f util/.serverpod_util_root ]; then + echo "Run this script from the root of the Serverpod repository" + echo "I.e. util/run_tests_flutter_integration" + exit 1 +fi + +# Makes script exit on first non-zero error code +set -e + +cd tests/docker/tests_flutter + +docker compose up --abort-on-container-exit --exit-code-from tests --build --remove-orphans \ No newline at end of file diff --git a/util/run_tests_unit b/util/run_tests_unit index 9a8dbdff18..a5d0fecc81 100755 --- a/util/run_tests_unit +++ b/util/run_tests_unit @@ -16,6 +16,7 @@ declare -a projectPaths=( "packages/serverpod_serialization" "tests/serverpod_test_client" "tests/serverpod_test_server" + "tests/serverpod_test_flutter" "modules/serverpod_auth/serverpod_auth_server" ) diff --git a/util/update_pubspecs b/util/update_pubspecs index 2289dafa42..3dfd35a85d 100755 --- a/util/update_pubspecs +++ b/util/update_pubspecs @@ -39,6 +39,7 @@ cp CHANGELOG.md tests/serverpod_test_client/CHANGELOG.md cp CHANGELOG.md tests/serverpod_test_server/CHANGELOG.md cp CHANGELOG.md tests/serverpod_test_module/serverpod_test_module_client/CHANGELOG.md cp CHANGELOG.md tests/serverpod_test_module/serverpod_test_module_server/CHANGELOG.md +cp CHANGELOG.md tests/serverpod_test_flutter/CHANGELOG.md cp CHANGELOG.md modules/serverpod_auth/serverpod_auth_server/CHANGELOG.md cp CHANGELOG.md modules/serverpod_auth/serverpod_auth_client/CHANGELOG.md @@ -71,6 +72,7 @@ cp README_subpackage.md tests/serverpod_test_client/README.md cp README_subpackage.md tests/serverpod_test_server/README.md cp README_subpackage.md tests/serverpod_test_module/serverpod_test_module_client/README.md cp README_subpackage.md tests/serverpod_test_module/serverpod_test_module_server/README.md +cp README_subpackage.md tests/serverpod_test_flutter/README.md cp README_subpackage.md modules/serverpod_auth/serverpod_auth_server/README.md cp README_subpackage.md modules/serverpod_auth/serverpod_auth_client/README.md