From 9f5f286cbf7ce094c89182886d04b7090af649dc Mon Sep 17 00:00:00 2001 From: Sebastian Neuser Date: Wed, 26 Nov 2025 15:17:20 +0100 Subject: [PATCH 1/8] feat: Greenlight 3 import command --- .../Commands/ImportGreenlight3Command.php | 317 ++++++++++++++++++ .../Controllers/api/v1/RoomController.php | 2 +- app/Http/Requests/UpdateRoomSettings.php | 9 +- app/Models/Room.php | 3 +- resources/js/components/RoomShareButton.vue | 8 +- resources/js/views/RoomsView.vue | 4 +- .../Backend/Feature/api/v1/Room/RoomTest.php | 6 + .../Unit/Console/ImportGreenlight3Test.php | 315 +++++++++++++++++ .../Unit/Console/helper/Greenlight3Room.php | 28 ++ .../Unit/Console/helper/Greenlight3User.php | 28 ++ tests/Frontend/e2e/RoomsViewGeneral.cy.js | 4 +- tests/Frontend/e2e/RoomsViewSettings.cy.js | 67 ++++ 12 files changed, 780 insertions(+), 11 deletions(-) create mode 100644 app/Console/Commands/ImportGreenlight3Command.php create mode 100644 tests/Backend/Unit/Console/ImportGreenlight3Test.php create mode 100644 tests/Backend/Unit/Console/helper/Greenlight3Room.php create mode 100644 tests/Backend/Unit/Console/helper/Greenlight3User.php diff --git a/app/Console/Commands/ImportGreenlight3Command.php b/app/Console/Commands/ImportGreenlight3Command.php new file mode 100644 index 000000000..311372619 --- /dev/null +++ b/app/Console/Commands/ImportGreenlight3Command.php @@ -0,0 +1,317 @@ + 'pgsql', + 'host' => $this->argument('host'), + 'database' => $this->argument('database'), + 'username' => $this->argument('username'), + 'password' => $this->argument('password'), + 'port' => $this->argument('port'), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'schema' => 'public', + 'sslmode' => 'prefer', + ]); + + $users = DB::connection('greenlight')->table('users')->where('deleted', false)->where('provider', 'greenlight')->get(['id', 'name', 'email', 'external_id', 'password_digest']); + $rooms = DB::connection('greenlight')->table('rooms')->where('deleted', false)->get(['id', 'friendly_id', 'user_id', 'name']); + $sharedAccesses = DB::connection('greenlight')->table('shared_accesses')->get(['room_id', 'user_id']); + + // ask user what room type the imported rooms should get + $roomType = select( + label: 'What room type should the rooms be assigned to?', + options: RoomType::pluck('name', 'id'), + scroll: 10 + ); + + // ask user to add prefix to room names + $prefix = text( + label: 'Prefix for room names', + placeholder: 'E.g. (Imported)', + hint: '(Optional).' + ); + + // ask user what room type the imported rooms should get + $defaultRole = select( + 'Please select the default role for new imported non-ldap users', + options: Role::pluck('name', 'id'), + scroll: 10 + ); + + // Start transaction to rollback if import fails or user cancels + DB::beginTransaction(); + + try { + $userMap = $this->importUsers($users, $defaultRole); + $roomMap = $this->importRooms($rooms, $roomType, $userMap, $prefix); + $this->importSharedAccesses($sharedAccesses, $roomMap, $userMap); + + if (confirm('Do you wish to commit the import?')) { + DB::commit(); + $this->info('Import completed'); + } else { + DB::rollBack(); + $this->warn('Import canceled; nothing was imported'); + } + } catch (\Exception $e) { + DB::rollBack(); + $this->error('Import failed: '.$e->getMessage()); + } + } + + /** + * Process greenlight user collection and try to import users + * + * @param Collection $users Collection with all users found in the greenlight database + * @param int $defaultRole IDs of the role that should be assigned to new non-ldap users + * @return array Array map of greenlight user ids as key and id of the found/created user as value + */ + protected function importUsers(Collection $users, int $defaultRole): array + { + $this->line('Importing users'); + $userMap = []; + + $bar = progress(label: 'Importing users', steps: $users->count()); + $bar->start(); + + // counter of user ids that already exists + $existed = 0; + // counter of users that are created + $created = 0; + + foreach ($users as $user) { + // check if user with this email exists + $dbUser = User::where('email', $user->email)->first(); + if ($dbUser != null) { + // user found, link greenlight user id to id of found user + $existed++; + } else { + // create new user + $dbUser = new User; + $dbUser->authenticator = $user->external_id ? 'oidc' : 'local'; + $dbUser->external_id = $user->external_id; + $dbUser->email = $user->email; + // as greenlight doesn't split the name in first and lastname, + // we have to import it as firstname and ask the users or admins to correct it later if desired + $dbUser->firstname = $user->name; + $dbUser->lastname = ''; + $dbUser->password = $user->external_id ? Hash::make(Str::random()) : $user->password_digest; + $dbUser->locale = config('app.locale'); + $dbUser->timezone = app(GeneralSettings::class)->default_timezone; + $dbUser->save(); + + if (! $user->external_id) { + $dbUser->roles()->attach($defaultRole); + } + + // user was successfully created, link greenlight user id to id of new user + $created++; + } + $userMap[$user->id] = $dbUser->id; + $bar->advance(); + } + + $bar->finish(); + + // show import results + $this->line(''); + $this->info($created.' created, '.$existed.' skipped (already existed)'); + + $this->line(''); + + return $userMap; + } + + /** + * Process greenlight room collection and create the rooms if not already existing + * + * @param Collection $rooms Collection with rooms users found in the greenlight database + * @param int $roomType ID of the roomtype the rooms should be assigned to + * @param array $userMap Array map of greenlight user ids as key and id of the found/created user as value + * @param string|null $prefix Prefix to add to room names + * @return array Array map of greenlight room ids as key and id of the created room as value + */ + protected function importRooms(Collection $rooms, int $roomType, array $userMap, ?string $prefix): array + { + $this->line('Importing rooms'); + + $bar = $this->output->createProgressBar($rooms->count()); + $bar->start(); + + // counter of room ids that already exists + $existed = 0; + // counter of rooms that are created + $created = 0; + // list of rooms that could not be created, e.g. room owner not found + $failed = []; + // array with the key being the greenlight id and value the new object id + $roomMap = []; + + // walk through all found greenlight rooms + foreach ($rooms as $room) { + // check if a room with the same id exists + $dbRoom = Room::find($room->friendly_id); + if ($dbRoom != null) { + // if found add counter but not add to room map + // this also prevents adding shared access, as we can't know if this id collision belongs to the same room + // and a shared access import is desired + $existed++; + $bar->advance(); + + continue; + } + + // try to find owner of this room + if (! isset($userMap[$room->user_id])) { + // if owner was not found, eg. missing in the greenlight db or user import failed, don't import room + array_push($failed, [$room->name, $room->friendly_id]); + $bar->advance(); + + continue; + } + + // create room with same id, same name, access code + $dbRoom = new Room; + $dbRoom->id = $room->friendly_id; + $dbRoom->name = Str::limit(($prefix != null ? ($prefix.' ') : '').$room->name, 253); // if prefix given, add prefix separated by a space from the title; truncate after 253 chars to prevent too long room names + $roomOptions = DB::connection('greenlight')->table('room_meeting_options')->join('meeting_options', 'meeting_option_id', '=', 'meeting_options.id')->where('room_id', $room->id)->get(['name', 'value']); + + // set room settings + foreach ($roomOptions as $option) { + switch ($option->name) { + case 'glAnyoneCanStart': + $dbRoom->everyone_can_start = $option->value === 'true'; + break; + case 'glAnyoneJoinAsModerator': + $dbRoom->default_role = $option->value ? RoomUserRole::MODERATOR : RoomUserRole::USER; + break; + case 'glRequireAuthentication': + $dbRoom->allow_guests = ! $option->value === 'true'; + break; + case 'glViewerAccessCode': + $dbRoom->access_code = $option->value; + break; + case 'guestPolicy': + $dbRoom->lobby = $option->value == 'ASK_MODERATOR' ? RoomLobby::ENABLED : RoomLobby::DISABLED; + break; + case 'muteOnStart': + $dbRoom->mute_on_start = $option->value === 'true'; + break; + case 'record': + $dbRoom->record = $option->value === 'true'; + break; + } + } + + // associate room with the imported or found user + $dbRoom->owner()->associate($userMap[$room->user_id]); + // set room type to given roomType for this import batch + $dbRoom->roomType()->associate($roomType); + $dbRoom->save(); + + // increase counter and add room to room map (key = greenlight db id, value = new db id) + $created++; + $roomMap[$room->id] = $room->friendly_id; + $bar->advance(); + } + + // show import results + $this->line(''); + $this->info($created.' created, '.$existed.' skipped (already existed)'); + + // if any room imports failed, show room name, id and access code + if (count($failed) > 0) { + $this->line(''); + + $this->error('Room import failed for the following '.count($failed).' rooms, because no room owner was found:'); + $this->table( + ['Name', 'Friendly ID'], + $failed + ); + } + $this->line(''); + + return $roomMap; + } + + /** + * Process greenlight shared room access collection and try to create the room membership for the users and rooms + * Each user get the moderator role, as that is the greenlight equivalent + * + * @param Collection $sharedAccesses Collection of user and room ids for shared room access + * @param array $roomMap Array map of greenlight room ids as key and id of the created room as value + * @param array $userMap Array map of greenlight user ids as key and id of the found/created user as value + */ + protected function importSharedAccesses(Collection $sharedAccesses, array $roomMap, array $userMap) + { + $this->line('Importing shared room accesses'); + + $bar = $this->output->createProgressBar($sharedAccesses->count()); + $bar->start(); + + // counter of shared accesses that are created + $created = 0; + // counter of shared accesses that could not be created, eg. room or user not imported + $failed = 0; + + // walk through all found greenlight shared accesses + foreach ($sharedAccesses as $sharedAccess) { + $room = $sharedAccess->room_id; + $user = $sharedAccess->user_id; + + // check if user id and room id are found in the imported rooms + if (! isset($userMap[$user]) || ! isset($roomMap[$room])) { + // one or both are not found + $bar->advance(); + $failed++; + + continue; + } + + // find room object and add user as moderator to the room + $dbRoom = Room::find($roomMap[$room]); + $dbRoom->members()->syncWithoutDetaching([$userMap[$user] => ['role' => RoomUserRole::MODERATOR]]); + $bar->advance(); + $created++; + } + + // show import result + $this->line(''); + $this->info($created.' created, '.$failed.' skipped (user or room not found)'); + } +} diff --git a/app/Http/Controllers/api/v1/RoomController.php b/app/Http/Controllers/api/v1/RoomController.php index 736f822a2..e7a9dc4b1 100644 --- a/app/Http/Controllers/api/v1/RoomController.php +++ b/app/Http/Controllers/api/v1/RoomController.php @@ -467,7 +467,7 @@ public function authenticate(Room $room, RoomAuthRequest $request) $accessCode = $request->access_code; - if (is_numeric($accessCode) && $room->access_code == $accessCode) { + if ($room->access_code == $accessCode) { // Generate new room auth token or retrieve existing one $roomAuthToken = RoomAuthToken::firstOrCreate([ 'room_id' => $room->id, diff --git a/app/Http/Requests/UpdateRoomSettings.php b/app/Http/Requests/UpdateRoomSettings.php index 7ad1bb711..1d9e39284 100644 --- a/app/Http/Requests/UpdateRoomSettings.php +++ b/app/Http/Requests/UpdateRoomSettings.php @@ -42,12 +42,17 @@ public function rules() */ private function getAccessCodeValidationRule(): array { - // Support keeping old 6-digit codes (Greenlight v2) + // Support keeping + // * old 6-digit numeric codes (Greenlight v2) + // * old 6-digit alphanumeric codes (Greenlight v3) $current = $this->room->access_code ?? ''; $incoming = $this->str('access_code') ?? ''; + $alphanumeric = $current == $incoming && ! is_numeric($current); $digits = ($current == $incoming && strlen($current) == 6) ? 6 : 9; - $rules = ['numeric', 'digits:'.$digits, 'bail']; + $rules = $alphanumeric + ? ['alpha_num:ascii', 'lowercase', 'size:6', 'bail'] + : ['numeric', 'digits:'.$digits, 'bail']; // Make sure that the given room type id is a number if (is_numeric($this->input('room_type'))) { diff --git a/app/Models/Room.php b/app/Models/Room.php index 70591c156..9e20fbffe 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -338,7 +338,8 @@ public function getModeratorOnlyMessage() $message = __('rooms.invitation.room', ['roomname' => $this->name, 'platform' => $appName]).'
'; $message .= __('rooms.invitation.link').': '.config('app.url').'/rooms/'.$this->id; if ($this->access_code != null) { - $message .= '
'.__('rooms.invitation.code').': '.implode('-', str_split($this->access_code, 3)); + $message .= '
'.__('rooms.invitation.code').': '; + $message .= $this->legacy_code ? $this->access_code : implode('-', str_split($this->access_code, 3)); } return $message; diff --git a/resources/js/components/RoomShareButton.vue b/resources/js/components/RoomShareButton.vue index 0ab1baff2..0eb7f2038 100644 --- a/resources/js/components/RoomShareButton.vue +++ b/resources/js/components/RoomShareButton.vue @@ -147,8 +147,10 @@ const roomUrl = computed(() => { }); const formattedAccessCode = computed(() => { - return String(props.room.access_code) - .match(/.{1,3}/g) - .join("-"); + return isNaN(props.room.access_code) + ? props.room.access_code + : String(props.room.access_code) + .match(/.{1,3}/g) + .join("-"); }); diff --git a/resources/js/views/RoomsView.vue b/resources/js/views/RoomsView.vue index 298d633b7..31748946d 100644 --- a/resources/js/views/RoomsView.vue +++ b/resources/js/views/RoomsView.vue @@ -120,8 +120,8 @@ id="access-code" v-model="accessCodeInput" autofocus - :mask="room.legacy_code ? '999-999' : '999-999-999'" - :placeholder="room.legacy_code ? '123-456' : '123-456-789'" + :mask="room.legacy_code ? '******' : '999-999-999'" + :placeholder="room.legacy_code ? '123abc' : '123-456-789'" :invalid=" accessCodeInvalid || formErrors.fieldInvalid('access_code') diff --git a/tests/Backend/Feature/api/v1/Room/RoomTest.php b/tests/Backend/Feature/api/v1/Room/RoomTest.php index 3044b0893..8b2e56867 100644 --- a/tests/Backend/Feature/api/v1/Room/RoomTest.php +++ b/tests/Backend/Feature/api/v1/Room/RoomTest.php @@ -675,6 +675,12 @@ public function test_auth_with_access_code_guests() 'session_id' => $currentSession->id, 'type' => RoomAuthTokenType::CODE->value, ]); + + // Try with legacy alphanumeric access code + $room->access_code = '012abc'; + $room->save(); + $this->postJson(route('api.v1.rooms.authenticate', ['room' => $room]), ['type' => RoomAuthTokenType::CODE->value, 'access_code' => $room->access_code]) + ->assertStatus(201); } /** diff --git a/tests/Backend/Unit/Console/ImportGreenlight3Test.php b/tests/Backend/Unit/Console/ImportGreenlight3Test.php new file mode 100644 index 000000000..f245b66d5 --- /dev/null +++ b/tests/Backend/Unit/Console/ImportGreenlight3Test.php @@ -0,0 +1,315 @@ + 'admin', + ]); + + Role::firstOrCreate([ + 'name' => 'student', + ]); + } + + /** + * Mock DB with fake response of the postgres database + * + * @param Collection $users Collection of Users + * @param Collection $rooms Collection Collection of Rooms + * @param Collection $sharedAccesses Collection Collection of SharedAccesses + */ + private function fakeDatabase(Collection $users, Collection $rooms, Collection $sharedAccesses) + { + // preserve DB default + $connection = DB::connection(); + + DB::shouldReceive('connection') + ->with(null) + ->andReturn($connection); + + // mock connection to greenlight postgres database and queries + DB::shouldReceive('connection') + ->with('greenlight') + ->andReturn(Mockery::mock('Illuminate\Database\Connection', function ($mock) use ($sharedAccesses, $rooms, $users) { + $mock->shouldReceive('table') + ->with('users') + ->once() + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($users) { + $mock->shouldReceive('where') + ->with('deleted', false) + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($users) { + $mock->shouldReceive('where') + ->with('provider', 'greenlight') + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($users) { + $mock->shouldReceive('get') + ->with(['id', 'name', 'email', 'external_id', 'password_digest']) + ->andReturn($users); + })); + })); + })); + + $mock->shouldReceive('table') + ->with('rooms') + ->once() + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($rooms) { + $mock->shouldReceive('where') + ->with('deleted', false) + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($rooms) { + $mock->shouldReceive('get') + ->with(['id', 'friendly_id', 'user_id', 'name']) + ->andReturn($rooms); + })); + })); + + $mock->shouldReceive('table') + ->with('shared_accesses') + ->once() + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($sharedAccesses) { + $mock->shouldReceive('get') + ->with(['room_id', 'user_id']) + ->andReturn($sharedAccesses); + })); + + $mock->shouldReceive('table') + ->with('room_meeting_options') + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) { + $mock->shouldReceive('join') + ->with('meeting_options', 'meeting_option_id', '=', 'meeting_options.id') + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) { + $roomOptions = [ + 1 => [], + 2 => [], + 3 => [ + (object) [ + 'name' => 'record', + 'value' => true, + ], + (object) [ + 'name' => 'glRequireAuthentication', + 'value' => false, + ], + ], + 4 => [], + 5 => [ + (object) [ + 'name' => 'glViewerAccessCode', + 'value' => '012345abcd', + ], + (object) [ + 'name' => 'guestPolicy', + 'value' => 'ASK_MODERATOR', + ], + (object) [ + 'name' => 'glAnyoneJoinAsModerator', + 'value' => true, + ], + ], + 6 => [ + (object) [ + 'name' => 'muteOnStart', + 'value' => true, + ], + (object) [ + 'name' => 'glAnyoneCanStart', + 'value' => true, + ], + ], + 7 => [], + 8 => [], + ]; + foreach ($roomOptions as $i => $options) { + $mock->shouldReceive('where') + ->with('room_id', $i) + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($options) { + $mock->shouldReceive('get') + ->with(['name', 'value']) + ->andReturn($options); + })); + } + })); + })); + })); + + DB::shouldReceive('beginTransaction')->once(); + DB::shouldReceive('rollBack')->never(); + DB::shouldReceive('commit')->once(); + } + + protected function test_command(?string $prefix = null) + { + // password for all users + $password = Hash::make('secret'); + + // create user that exists before import + $existingUser = new User; + $existingUser->firstname = 'John'; + $existingUser->lastname = 'Doe'; + $existingUser->email = 'john.doe@domain.tld'; + $existingUser->password = $password; + $existingUser->save(); + + // create room that exists before import + $existingRoom = new Room; + $existingRoom->name = 'Existing room 1'; + $existingRoom->roomType()->associate(RoomType::all()->first()); + $existingRoom->owner()->associate($existingUser); + $existingRoom->save(); + + // Create fake users, ldap users and social users + $users = []; + $users[] = new Greenlight3User(1, 'John Doe', 'john.doe@domain.tld', null, $password); + $users[] = new Greenlight3User(2, 'John Doe', 'john@domain.tld', null, $password); + $users[] = new Greenlight3User(3, 'John Doe', 'j.doe@domain.tld', '79b3db28-31a9-42bf-ac9a-49bdf13b6cc1', null); + $users[] = new Greenlight3User(4, 'John Doe', 'j.doe@domain.tld', null, $password); + + // Create fake rooms + $rooms = []; + $rooms[] = new Greenlight3Room(1, 'abc-def-xyz-123', $users[0]->id, 'Test Room 1'); + $rooms[] = new Greenlight3Room(2, 'abc-def-xyz-234', $users[1]->id, 'Test Room 2'); + $rooms[] = new Greenlight3Room(3, 'abc-def-xyz-345', $users[2]->id, 'Test Room 3'); + $rooms[] = new Greenlight3Room(4, 'abc-def-xyz-456', $users[3]->id, 'Test Room 4'); + + $rooms[] = new Greenlight3Room(5, 'hij-klm-xyz-123', $users[0]->id, 'Test Room 5'); + $rooms[] = new Greenlight3Room(6, 'hij-klm-xyz-234', $users[0]->id, 'Test Room 6'); + $rooms[] = new Greenlight3Room(7, 'hij-klm-xyz-456', 99, 'Test Room 9', true); + $rooms[] = new Greenlight3Room(8, $existingRoom->id, $users[0]->id, 'Test Room 10'); + + // Create fake shared accesses + $sharedAccesses = []; + $sharedAccesses[] = new GreenlightSharedAccess(1, 1, 2); + $sharedAccesses[] = new GreenlightSharedAccess(2, 1, 3); // shared access should be applied for existing users + $sharedAccesses[] = new GreenlightSharedAccess(3, 1, 4); + + $sharedAccesses[] = new GreenlightSharedAccess(4, 1, 99); // invalid user id + $sharedAccesses[] = new GreenlightSharedAccess(5, 7, 1); // room that has an invalid owner + $sharedAccesses[] = new GreenlightSharedAccess(6, 8, 1); // room that already exists should not be modified + + // mock database connections with fake data + $this->fakeDatabase(new Collection($users), new Collection($rooms), new Collection($sharedAccesses)); + + $roomType = RoomType::where('name', 'Lecture')->first(); + $role = Role::where('name', 'student')->first(); + + // run artisan command and text questions and outputs + $this->artisan('import:greenlight-v3 localhost 5432 greenlight_production postgres 12345678') + ->expectsQuestion('What room type should the rooms be assigned to?', $roomType->id) + ->expectsQuestion('Prefix for room names', $prefix) + ->expectsQuestion('Please select the default role for new imported non-ldap users', $role->id) + ->expectsOutput('Importing users') + ->expectsOutput('2 created, 2 skipped (already existed)') + ->expectsOutput('Importing rooms') + ->expectsOutput('6 created, 1 skipped (already existed)') + ->expectsOutput('Room import failed for the following 1 rooms, because no room owner was found:') + ->expectsTable(['Name', 'Friendly ID'], [['Test Room 9', 'hij-klm-xyz-456']]) + ->expectsOutput('Importing shared room accesses') + ->expectsOutput('3 created, 3 skipped (user or room not found)') + ->expectsQuestion('Do you wish to commit the import?', 'yes') + ->expectsOutput('Import completed') + ->assertSuccessful(); + + // check amount of users and rooms + $this->assertCount(2, User::where('authenticator', 'local')->get()); + $this->assertCount(1, User::where('authenticator', 'oidc')->get()); + $this->assertCount(7, Room::all()); + + // Check users + $this->assertNotNull(User::where([['authenticator', 'local'], ['firstname', 'John'], ['lastname', 'Doe'], ['email', 'john.doe@domain.tld'], ['external_id', null], ['password', $password]])->first()); + $this->assertNotNull(User::where([['authenticator', 'local'], ['firstname', 'John Doe'], ['lastname', ''], ['email', 'john@domain.tld'], ['external_id', null], ['password', $password]])->first()); + $this->assertNotNull(User::where([['authenticator', 'oidc'], ['firstname', 'John Doe'], ['lastname', ''], ['email', 'j.doe@domain.tld'], ['external_id', '79b3db28-31a9-42bf-ac9a-49bdf13b6cc1']])->first()); + + // Check user roles + $this->assertEquals(['student'], User::where([['authenticator', 'local'], ['firstname', 'John Doe'], ['lastname', ''], ['email', 'john@domain.tld'], ['external_id', null], ['password', $password]])->first()->roles->pluck('name')->toArray()); + + // Check OIDC users and existing users don't get a default role assigned + $this->assertCount(0, User::where([['authenticator', 'oidc'], ['firstname', 'John Doe'], ['lastname', ''], ['email', 'j.doe@domain.tld'], ['external_id', '79b3db28-31a9-42bf-ac9a-49bdf13b6cc1']])->first()->roles); + $this->assertCount(0, User::where([['authenticator', 'local'], ['firstname', 'John'], ['lastname', 'Doe'], ['email', 'john.doe@domain.tld'], ['external_id', null], ['password', $password]])->first()->roles); + + // check if all rooms are created + $this->assertEqualsCanonicalizing( + [$existingRoom->id, 'abc-def-xyz-123', 'abc-def-xyz-234', 'abc-def-xyz-345', 'abc-def-xyz-456', 'hij-klm-xyz-123', 'hij-klm-xyz-234'], + Room::all()->pluck('id')->toArray() + ); + + // check access code + $this->assertNull(Room::find('abc-def-xyz-123')->access_code); + $this->assertNull(Room::find('abc-def-xyz-234')->access_code); + $this->assertNull(Room::find('abc-def-xyz-345')->access_code); + $this->assertNull(Room::find('abc-def-xyz-456')->access_code); + $this->assertEquals('012345abcd', Room::find('hij-klm-xyz-123')->access_code); + $this->assertNull(Room::find('hij-klm-xyz-234')->access_code); + + // check room settings + $this->assertFalse(Room::find('abc-def-xyz-234')->record); + $this->assertFalse(Room::find('abc-def-xyz-234')->allow_guests); + $this->assertTrue(Room::find('abc-def-xyz-345')->record); + $this->assertTrue(Room::find('abc-def-xyz-345')->allow_guests); + + $this->assertFalse(Room::find('hij-klm-xyz-123')->mute_on_start); + $this->assertFalse(Room::find('hij-klm-xyz-123')->everyone_can_start); + $this->assertEquals(RoomLobby::ENABLED, Room::find('hij-klm-xyz-123')->lobby); + $this->assertEquals(RoomUserRole::MODERATOR, Room::find('hij-klm-xyz-123')->default_role); + + $this->assertTrue(Room::find('hij-klm-xyz-234')->mute_on_start); + $this->assertTrue(Room::find('hij-klm-xyz-234')->everyone_can_start); + $this->assertEquals(RoomLobby::DISABLED, Room::find('hij-klm-xyz-234')->lobby); + $this->assertEquals(RoomUserRole::USER, Room::find('hij-klm-xyz-234')->default_role); + + // Test room name prefix + if ($prefix != null) { + $this->assertEquals($prefix.' Test Room 1', Room::find('abc-def-xyz-123')->name); + } else { + $this->assertEquals('Test Room 1', Room::find('abc-def-xyz-123')->name); + } + + // Testing room ownership + $this->assertEquals(User::where('email', 'john.doe@domain.tld')->where('authenticator', 'local')->first(), Room::find('abc-def-xyz-123')->owner); + $this->assertEquals(User::where('email', 'john@domain.tld')->where('authenticator', 'local')->first(), Room::find('abc-def-xyz-234')->owner); + $this->assertEquals(User::where('email', 'j.doe@domain.tld')->where('authenticator', 'oidc')->where('external_id', '79b3db28-31a9-42bf-ac9a-49bdf13b6cc1')->first(), Room::find('abc-def-xyz-345')->owner); + + // Testing room memberships (should be moderator, as that is the greenlight equivalent) + $this->assertCount(2, Room::find('abc-def-xyz-123')->members); + foreach (Room::find('abc-def-xyz-123')->members as $member) { + $this->assertEquals(RoomUserRole::MODERATOR, $member->pivot->role); + } + $this->assertTrue(Room::find('abc-def-xyz-123')->members->contains(User::where('email', 'john@domain.tld')->where('authenticator', 'local')->first())); + } + + public function test() + { + $this->test_command(); + } + + public function test_with_prefix() + { + $this->test_command('Migration:'); + } +} diff --git a/tests/Backend/Unit/Console/helper/Greenlight3Room.php b/tests/Backend/Unit/Console/helper/Greenlight3Room.php new file mode 100644 index 000000000..27081c881 --- /dev/null +++ b/tests/Backend/Unit/Console/helper/Greenlight3Room.php @@ -0,0 +1,28 @@ +id = $id; + $this->friendly_id = $friendly_id; + $this->user_id = $user_id; + $this->name = $name; + $this->deleted = $deleted; + } +} diff --git a/tests/Backend/Unit/Console/helper/Greenlight3User.php b/tests/Backend/Unit/Console/helper/Greenlight3User.php new file mode 100644 index 000000000..cbef4a970 --- /dev/null +++ b/tests/Backend/Unit/Console/helper/Greenlight3User.php @@ -0,0 +1,28 @@ +id = $id; + $this->name = $name; + $this->email = $email; + $this->external_id = $external_id; + $this->password_digest = $password_digest; + } +} diff --git a/tests/Frontend/e2e/RoomsViewGeneral.cy.js b/tests/Frontend/e2e/RoomsViewGeneral.cy.js index 618aef221..d07a47528 100644 --- a/tests/Frontend/e2e/RoomsViewGeneral.cy.js +++ b/tests/Frontend/e2e/RoomsViewGeneral.cy.js @@ -428,7 +428,7 @@ describe("Room View general", function () { cy.contains("rooms.require_access_code").should("be.visible"); // Submit valid access code - cy.get("#access-code").type("012345"); + cy.get("#access-code").type("012abc"); }); // Intercept room auth request @@ -460,7 +460,7 @@ describe("Room View general", function () { cy.wait("@roomAuthRequest").then((interception) => { expect(interception.request.body).to.eql({ - access_code: "012345", + access_code: "012abc", type: 0, }); }); diff --git a/tests/Frontend/e2e/RoomsViewSettings.cy.js b/tests/Frontend/e2e/RoomsViewSettings.cy.js index 87146e72f..125adf234 100644 --- a/tests/Frontend/e2e/RoomsViewSettings.cy.js +++ b/tests/Frontend/e2e/RoomsViewSettings.cy.js @@ -1236,6 +1236,73 @@ describe("Rooms view settings", function () { ); }); + it("change settings with GL3 access code", function () { + cy.fixture("roomSettings.json").then((roomSettings) => { + roomSettings.data.access_code = "fck4fd"; + cy.intercept("GET", "api/v1/rooms/abc-def-123/settings", { + statusCode: 200, + body: roomSettings, + }).as("roomSettingsRequest"); + }); + + cy.visit("/rooms/abc-def-123#tab=settings"); + cy.wait("@roomSettingsRequest"); + + cy.get("#room-setting-access_code") + .should("have.value", "fck4fd") + .and("not.be.disabled"); + + // Change settings + cy.get("#room-setting-name").clear(); + cy.get("#room-setting-name").type("Meeting Two"); + + // Save settings + cy.fixture("roomTypesWithSettings.json").then(() => { + cy.fixture("roomSettings.json").then((roomSettings) => { + roomSettings.data.name = "Meeting Two"; + roomSettings.data.access_code = "fck4fd"; + + cy.intercept("PUT", "api/v1/rooms/abc-def-123", { + statusCode: 200, + body: roomSettings, + }).as("roomSettingsSaveRequest"); + + cy.get('[data-test="room-settings-save-button"]').click(); + }); + }); + + cy.wait("@roomSettingsSaveRequest").then((interception) => { + expect(interception.request.body).to.eql({ + name: "Meeting Two", + expert_mode: true, + welcome: "Welcome message", + short_description: "Short description", + access_code: "fck4fd", + room_type: 2, + mute_on_start: true, + lock_settings_disable_cam: false, + webcams_only_for_moderator: true, + lock_settings_disable_mic: false, + lock_settings_disable_private_chat: false, + lock_settings_disable_public_chat: true, + lock_settings_disable_note: true, + lock_settings_hide_user_list: true, + everyone_can_start: false, + allow_membership: false, + allow_guests: true, + default_role: 1, + lobby: 2, + visibility: 1, + record_attendance: false, + record: false, + auto_start_recording: false, + }); + }); + + // Check that settings are shown correctly + cy.get("#room-setting-name").should("have.value", "Meeting Two"); + }); + it("change settings errors", function () { cy.fixture("roomTypesWithSettings.json").then((roomTypes) => { cy.fixture("roomSettings.json").then((roomSettings) => { From 2b11b133b313ccd113b04d7afa717015feb5d3eb Mon Sep 17 00:00:00 2001 From: Sebastian Neuser Date: Mon, 19 Jan 2026 20:36:47 +0100 Subject: [PATCH 2/8] fix: Users and rooms do not have a deleted field in GL3 DB --- app/Console/Commands/ImportGreenlight3Command.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/ImportGreenlight3Command.php b/app/Console/Commands/ImportGreenlight3Command.php index 311372619..ce4210f9b 100644 --- a/app/Console/Commands/ImportGreenlight3Command.php +++ b/app/Console/Commands/ImportGreenlight3Command.php @@ -48,8 +48,8 @@ public function handle() 'sslmode' => 'prefer', ]); - $users = DB::connection('greenlight')->table('users')->where('deleted', false)->where('provider', 'greenlight')->get(['id', 'name', 'email', 'external_id', 'password_digest']); - $rooms = DB::connection('greenlight')->table('rooms')->where('deleted', false)->get(['id', 'friendly_id', 'user_id', 'name']); + $users = DB::connection('greenlight')->table('users')->where('provider', 'greenlight')->get(['id', 'name', 'email', 'external_id', 'password_digest']); + $rooms = DB::connection('greenlight')->table('rooms')->get(['id', 'friendly_id', 'user_id', 'name']); $sharedAccesses = DB::connection('greenlight')->table('shared_accesses')->get(['room_id', 'user_id']); // ask user what room type the imported rooms should get From 13c7e251f15f56a6ddec3640833a3dcb66b6cb36 Mon Sep 17 00:00:00 2001 From: Sebastian Neuser Date: Tue, 20 Jan 2026 13:34:42 +0100 Subject: [PATCH 3/8] impr: Make GL3 import command non-interactive --- .../Commands/ImportGreenlight3Command.php | 42 +++++++++++-------- .../Unit/Console/ImportGreenlight3Test.php | 22 ++++------ 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/app/Console/Commands/ImportGreenlight3Command.php b/app/Console/Commands/ImportGreenlight3Command.php index ce4210f9b..ebe666f68 100644 --- a/app/Console/Commands/ImportGreenlight3Command.php +++ b/app/Console/Commands/ImportGreenlight3Command.php @@ -24,11 +24,15 @@ class ImportGreenlight3Command extends Command { protected $signature = 'import:greenlight-v3 - {host : ip or hostname of postgres database server} - {port : port of postgres database server} - {database : greenlight database name, see greenlight .env variable DB_NAME} - {username : greenlight database username, see greenlight .env variable DB_USERNAME} - {password : greenlight database password, see greenlight .env variable DB_PASSWORD}'; + {host : ip or hostname of postgres database server} + {port : port of postgres database server} + {database : greenlight database name, see greenlight .env variable DB_NAME} + {username : greenlight database username, see greenlight .env variable DB_USERNAME} + {password : greenlight database password, see greenlight .env variable DB_PASSWORD} + {--no-confirm : do not ask if the import should be committed} + {--default-role= : default role for imported external users} + {--room-type= : room type for imported rooms} + '; protected $description = 'Connect to greenlight PostgreSQL database to import users, rooms and shared room accesses'; @@ -53,25 +57,29 @@ public function handle() $sharedAccesses = DB::connection('greenlight')->table('shared_accesses')->get(['room_id', 'user_id']); // ask user what room type the imported rooms should get - $roomType = select( - label: 'What room type should the rooms be assigned to?', - options: RoomType::pluck('name', 'id'), - scroll: 10 - ); + $roomType = $this->option('room-type') != null + ? RoomType::firstWhere('name', $this->option('room-type'))->id + : select( + label: 'What room type should the rooms be assigned to?', + options: RoomType::pluck('name', 'id'), + scroll: 10 + ); // ask user to add prefix to room names - $prefix = text( + $prefix = $this->option('no-confirm') ? null : text( label: 'Prefix for room names', placeholder: 'E.g. (Imported)', hint: '(Optional).' ); // ask user what room type the imported rooms should get - $defaultRole = select( - 'Please select the default role for new imported non-ldap users', - options: Role::pluck('name', 'id'), - scroll: 10 - ); + $defaultRole = $this->option('default-role') != null + ? Role::firstWhere('name', $this->option('default-role'))->id + : select( + 'Please select the default role for new imported non-ldap users', + options: Role::pluck('name', 'id'), + scroll: 10 + ); // Start transaction to rollback if import fails or user cancels DB::beginTransaction(); @@ -81,7 +89,7 @@ public function handle() $roomMap = $this->importRooms($rooms, $roomType, $userMap, $prefix); $this->importSharedAccesses($sharedAccesses, $roomMap, $userMap); - if (confirm('Do you wish to commit the import?')) { + if ($this->option('no-confirm') || confirm('Do you wish to commit the import?')) { DB::commit(); $this->info('Import completed'); } else { diff --git a/tests/Backend/Unit/Console/ImportGreenlight3Test.php b/tests/Backend/Unit/Console/ImportGreenlight3Test.php index f245b66d5..d1761de31 100644 --- a/tests/Backend/Unit/Console/ImportGreenlight3Test.php +++ b/tests/Backend/Unit/Console/ImportGreenlight3Test.php @@ -65,15 +65,11 @@ private function fakeDatabase(Collection $users, Collection $rooms, Collection $ ->once() ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($users) { $mock->shouldReceive('where') - ->with('deleted', false) + ->with('provider', 'greenlight') ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($users) { - $mock->shouldReceive('where') - ->with('provider', 'greenlight') - ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($users) { - $mock->shouldReceive('get') - ->with(['id', 'name', 'email', 'external_id', 'password_digest']) - ->andReturn($users); - })); + $mock->shouldReceive('get') + ->with(['id', 'name', 'email', 'external_id', 'password_digest']) + ->andReturn($users); })); })); @@ -81,13 +77,9 @@ private function fakeDatabase(Collection $users, Collection $rooms, Collection $ ->with('rooms') ->once() ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($rooms) { - $mock->shouldReceive('where') - ->with('deleted', false) - ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($rooms) { - $mock->shouldReceive('get') - ->with(['id', 'friendly_id', 'user_id', 'name']) - ->andReturn($rooms); - })); + $mock->shouldReceive('get') + ->with(['id', 'friendly_id', 'user_id', 'name']) + ->andReturn($rooms); })); $mock->shouldReceive('table') From 3ab86e94cd8741a09f795f64421dd95214ff62b3 Mon Sep 17 00:00:00 2001 From: Samuel Weirich <4281791+samuelwei@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:44:55 +0100 Subject: [PATCH 4/8] Remove profanity --- tests/Frontend/e2e/RoomsViewSettings.cy.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Frontend/e2e/RoomsViewSettings.cy.js b/tests/Frontend/e2e/RoomsViewSettings.cy.js index 125adf234..af4f2e29e 100644 --- a/tests/Frontend/e2e/RoomsViewSettings.cy.js +++ b/tests/Frontend/e2e/RoomsViewSettings.cy.js @@ -1238,7 +1238,7 @@ describe("Rooms view settings", function () { it("change settings with GL3 access code", function () { cy.fixture("roomSettings.json").then((roomSettings) => { - roomSettings.data.access_code = "fck4fd"; + roomSettings.data.access_code = "abc123"; cy.intercept("GET", "api/v1/rooms/abc-def-123/settings", { statusCode: 200, body: roomSettings, @@ -1249,7 +1249,7 @@ describe("Rooms view settings", function () { cy.wait("@roomSettingsRequest"); cy.get("#room-setting-access_code") - .should("have.value", "fck4fd") + .should("have.value", "abc123") .and("not.be.disabled"); // Change settings @@ -1260,7 +1260,7 @@ describe("Rooms view settings", function () { cy.fixture("roomTypesWithSettings.json").then(() => { cy.fixture("roomSettings.json").then((roomSettings) => { roomSettings.data.name = "Meeting Two"; - roomSettings.data.access_code = "fck4fd"; + roomSettings.data.access_code = "abc123"; cy.intercept("PUT", "api/v1/rooms/abc-def-123", { statusCode: 200, @@ -1277,7 +1277,7 @@ describe("Rooms view settings", function () { expert_mode: true, welcome: "Welcome message", short_description: "Short description", - access_code: "fck4fd", + access_code: "abc123", room_type: 2, mute_on_start: true, lock_settings_disable_cam: false, From 78d06ba4535d458e0eb613c603023f5b31fa408c Mon Sep 17 00:00:00 2001 From: Samuel Weirich <4281791+samuelwei@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:47:18 +0100 Subject: [PATCH 5/8] Use access code with leading zero to test for bugs removing leading zeros --- tests/Frontend/e2e/RoomsViewSettings.cy.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Frontend/e2e/RoomsViewSettings.cy.js b/tests/Frontend/e2e/RoomsViewSettings.cy.js index af4f2e29e..fb9eeed22 100644 --- a/tests/Frontend/e2e/RoomsViewSettings.cy.js +++ b/tests/Frontend/e2e/RoomsViewSettings.cy.js @@ -1238,7 +1238,7 @@ describe("Rooms view settings", function () { it("change settings with GL3 access code", function () { cy.fixture("roomSettings.json").then((roomSettings) => { - roomSettings.data.access_code = "abc123"; + roomSettings.data.access_code = "012abc"; cy.intercept("GET", "api/v1/rooms/abc-def-123/settings", { statusCode: 200, body: roomSettings, @@ -1249,7 +1249,7 @@ describe("Rooms view settings", function () { cy.wait("@roomSettingsRequest"); cy.get("#room-setting-access_code") - .should("have.value", "abc123") + .should("have.value", "012abc") .and("not.be.disabled"); // Change settings @@ -1260,7 +1260,7 @@ describe("Rooms view settings", function () { cy.fixture("roomTypesWithSettings.json").then(() => { cy.fixture("roomSettings.json").then((roomSettings) => { roomSettings.data.name = "Meeting Two"; - roomSettings.data.access_code = "abc123"; + roomSettings.data.access_code = "012abc"; cy.intercept("PUT", "api/v1/rooms/abc-def-123", { statusCode: 200, @@ -1277,7 +1277,7 @@ describe("Rooms view settings", function () { expert_mode: true, welcome: "Welcome message", short_description: "Short description", - access_code: "abc123", + access_code: "012abc", room_type: 2, mute_on_start: true, lock_settings_disable_cam: false, From a93f7cad4ca28e81e1df1596f9d4da0eadbc91c1 Mon Sep 17 00:00:00 2001 From: Sebastian Neuser Date: Thu, 12 Feb 2026 17:44:42 +0100 Subject: [PATCH 6/8] WIP: impr(ImportGreenlight3Command): Import room presentations --- .../Commands/ImportGreenlight3Command.php | 70 +++++++++++++++++++ .../Unit/Console/ImportGreenlight3Test.php | 55 ++++++++++++++- .../Console/helper/Greenlight3Attachment.php | 25 +++++++ 3 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 tests/Backend/Unit/Console/helper/Greenlight3Attachment.php diff --git a/app/Console/Commands/ImportGreenlight3Command.php b/app/Console/Commands/ImportGreenlight3Command.php index ebe666f68..ef776addc 100644 --- a/app/Console/Commands/ImportGreenlight3Command.php +++ b/app/Console/Commands/ImportGreenlight3Command.php @@ -6,6 +6,7 @@ use App\Enums\RoomUserRole; use App\Models\Role; use App\Models\Room; +use App\Models\RoomFile; use App\Models\RoomType; use App\Models\User; use App\Settings\GeneralSettings; @@ -13,7 +14,9 @@ use DB; use Hash; use Illuminate\Console\Command; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Storage; use Str; use function Laravel\Prompts\confirm; @@ -32,6 +35,7 @@ class ImportGreenlight3Command extends Command {--no-confirm : do not ask if the import should be committed} {--default-role= : default role for imported external users} {--room-type= : room type for imported rooms} + {--presentation-path= : path to room presentations} '; protected $description = 'Connect to greenlight PostgreSQL database to import users, rooms and shared room accesses'; @@ -81,12 +85,21 @@ public function handle() scroll: 10 ); + // ask user for presentation path + $presentationPath = $this->option('presentation-path') + ? $this->option('presentation-path') + : text( + label: 'Path to GL3 room presentations', + placeholder: 'migration/presentations', + ); + // Start transaction to rollback if import fails or user cancels DB::beginTransaction(); try { $userMap = $this->importUsers($users, $defaultRole); $roomMap = $this->importRooms($rooms, $roomType, $userMap, $prefix); + $this->importPresentations($roomMap, $presentationPath); $this->importSharedAccesses($sharedAccesses, $roomMap, $userMap); if ($this->option('no-confirm') || confirm('Do you wish to commit the import?')) { @@ -277,6 +290,63 @@ protected function importRooms(Collection $rooms, int $roomType, array $userMap, return $roomMap; } + /** + * Process room mapping and import the rooms' presentations + * + * @param array $roomMap Array map of greenlight room ids as key and id of the created room as value + * @param array $presentationPath Path to GL3 presentation files in default storage + */ + protected function importPresentations(array $roomMap, string $presentationPath) + { + $this->line('Importing presentations for rooms'); + + $bar = $this->output->createProgressBar(count($roomMap)); + $bar->start(); + + // counter of successfully imported presentations + $created = 0; + // counter of failed imports + $failed = 0; + + foreach ($roomMap as $gl3RoomId => $pilosRoomId) { + $attachments = DB::connection('greenlight')->table('active_storage_attachments')->where('record_id', $gl3RoomId)->get('blob_id'); + foreach ($attachments as $attachment) { + // Read file path from GL3 database + $blob = DB::connection('greenlight')->table('active_storage_blobs')->find($attachment->blob_id); + $path = $presentationPath.'/'.substr($blob->key, 0, 2).'/'.substr($blob->key, 2, 2).'/'.$blob->key; + + try { + // Prepare uploaded file + $presentation = new UploadedFile(Storage::path($path), $blob->filename); + + // Construct RoomFile + $file = new RoomFile; + $file->path = $path; + $file->filename = $blob->filename; + $file->use_in_meeting = true; + $room = Room::find($pilosRoomId); + + // Save file and room, delete source file + $presentation->store($room->id); + $room->files()->save($file); + $room->updateDefaultFile(); + Storage::delete($path); + + $created++; + } catch (\Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException $e) { + $failed++; + + continue; + } + } + $bar->advance(); + } + + $this->line(''); + $this->info($created.' created, '.$failed.' skipped (file not found)'); + $this->line(''); + } + /** * Process greenlight shared room access collection and try to create the room membership for the users and rooms * Each user get the moderator role, as that is the greenlight equivalent diff --git a/tests/Backend/Unit/Console/ImportGreenlight3Test.php b/tests/Backend/Unit/Console/ImportGreenlight3Test.php index d1761de31..9236d9b7d 100644 --- a/tests/Backend/Unit/Console/ImportGreenlight3Test.php +++ b/tests/Backend/Unit/Console/ImportGreenlight3Test.php @@ -14,8 +14,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Storage; use Mockery; use Tests\Backend\TestCase; +use Tests\Backend\Unit\Console\helper\Greenlight3Attachment; use Tests\Backend\Unit\Console\helper\Greenlight3Room; use Tests\Backend\Unit\Console\helper\Greenlight3User; use Tests\Backend\Unit\Console\helper\GreenlightSharedAccess; @@ -46,8 +48,9 @@ protected function setUp(): void * @param Collection $users Collection of Users * @param Collection $rooms Collection Collection of Rooms * @param Collection $sharedAccesses Collection Collection of SharedAccesses + * @param Collection $presentations Collection Collection of presentations */ - private function fakeDatabase(Collection $users, Collection $rooms, Collection $sharedAccesses) + private function fakeDatabase(Collection $users, Collection $rooms, Collection $sharedAccesses, Collection $presentations) { // preserve DB default $connection = DB::connection(); @@ -59,7 +62,7 @@ private function fakeDatabase(Collection $users, Collection $rooms, Collection $ // mock connection to greenlight postgres database and queries DB::shouldReceive('connection') ->with('greenlight') - ->andReturn(Mockery::mock('Illuminate\Database\Connection', function ($mock) use ($sharedAccesses, $rooms, $users) { + ->andReturn(Mockery::mock('Illuminate\Database\Connection', function ($mock) use ($presentations, $sharedAccesses, $rooms, $users) { $mock->shouldReceive('table') ->with('users') ->once() @@ -149,6 +152,37 @@ private function fakeDatabase(Collection $users, Collection $rooms, Collection $ } })); })); + + $mock->shouldReceive('table') + ->with('active_storage_attachments') + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($presentations) { + foreach ($presentations as $pres) { + $mock->shouldReceive('where') + ->with('record_id', $pres->room_id) + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($pres) { + $mock->shouldReceive('get') + ->with('blob_id') + ->andReturn([$pres]); + })); + } + + $mock->shouldReceive('where') + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) { + $mock->shouldReceive('get') + ->with('blob_id') + ->andReturn([]); + })); + })); + + $mock->shouldReceive('table') + ->with('active_storage_blobs') + ->andReturn(Mockery::mock('Illuminate\Database\Query\Builder', function ($mock) use ($presentations) { + foreach ($presentations as $pres) { + $mock->shouldReceive('find') + ->with($pres->blob_id) + ->andReturn($pres); + } + })); })); DB::shouldReceive('beginTransaction')->once(); @@ -195,6 +229,10 @@ protected function test_command(?string $prefix = null) $rooms[] = new Greenlight3Room(7, 'hij-klm-xyz-456', 99, 'Test Room 9', true); $rooms[] = new Greenlight3Room(8, $existingRoom->id, $users[0]->id, 'Test Room 10'); + $presentations = []; + $presentations[] = new Greenlight3Attachment('d8db66f8-0915-4aba-b24d-82974300f359', 'feen6movahheegheeg0ovahche8bu3mo', '1testvongpresiher.pdf', '1'); + $presentations[] = new Greenlight3Attachment('9a91bf3d-e5c4-45c8-8df5-41c76ad403ec', 'xivei7mi0cohtoecacahyaich8ohzaed', '2testvongpresiher.pdf', '2'); + // Create fake shared accesses $sharedAccesses = []; $sharedAccesses[] = new GreenlightSharedAccess(1, 1, 2); @@ -206,22 +244,28 @@ protected function test_command(?string $prefix = null) $sharedAccesses[] = new GreenlightSharedAccess(6, 8, 1); // room that already exists should not be modified // mock database connections with fake data - $this->fakeDatabase(new Collection($users), new Collection($rooms), new Collection($sharedAccesses)); + $this->fakeDatabase(new Collection($users), new Collection($rooms), new Collection($sharedAccesses), new Collection($presentations)); $roomType = RoomType::where('name', 'Lecture')->first(); $role = Role::where('name', 'student')->first(); // run artisan command and text questions and outputs + $storageMock = Storage::fake(); + $storageMock->put('migration/presentations/fe/en/feen6movahheegheeg0ovahche8bu3mo', 'Foobar test 123'); + $this->artisan('import:greenlight-v3 localhost 5432 greenlight_production postgres 12345678') ->expectsQuestion('What room type should the rooms be assigned to?', $roomType->id) ->expectsQuestion('Prefix for room names', $prefix) ->expectsQuestion('Please select the default role for new imported non-ldap users', $role->id) + ->expectsQuestion('Path to GL3 room presentations', 'migration/presentations') ->expectsOutput('Importing users') ->expectsOutput('2 created, 2 skipped (already existed)') ->expectsOutput('Importing rooms') ->expectsOutput('6 created, 1 skipped (already existed)') ->expectsOutput('Room import failed for the following 1 rooms, because no room owner was found:') ->expectsTable(['Name', 'Friendly ID'], [['Test Room 9', 'hij-klm-xyz-456']]) + ->expectsOutput('Importing presentations for rooms') + ->expectsOutput('1 created, 1 skipped (file not found)') ->expectsOutput('Importing shared room accesses') ->expectsOutput('3 created, 3 skipped (user or room not found)') ->expectsQuestion('Do you wish to commit the import?', 'yes') @@ -282,6 +326,11 @@ protected function test_command(?string $prefix = null) $this->assertEquals('Test Room 1', Room::find('abc-def-xyz-123')->name); } + // Test presentations + $this->assertEquals('1testvongpresiher.pdf', Room::find('abc-def-xyz-123')->files()->first()->filename); + $this->assertEmpty(Room::find('abc-def-xyz-234')->files()->get()); + $this->assertEmpty(Room::find('abc-def-xyz-345')->files()->get()); + // Testing room ownership $this->assertEquals(User::where('email', 'john.doe@domain.tld')->where('authenticator', 'local')->first(), Room::find('abc-def-xyz-123')->owner); $this->assertEquals(User::where('email', 'john@domain.tld')->where('authenticator', 'local')->first(), Room::find('abc-def-xyz-234')->owner); diff --git a/tests/Backend/Unit/Console/helper/Greenlight3Attachment.php b/tests/Backend/Unit/Console/helper/Greenlight3Attachment.php new file mode 100644 index 000000000..7015a2c9d --- /dev/null +++ b/tests/Backend/Unit/Console/helper/Greenlight3Attachment.php @@ -0,0 +1,25 @@ +blob_id = $blob_id; + $this->key = $key; + $this->filename = $filename; + $this->room_id = $room_id; + } +} From 01c642a726d69504776ac364f5c1662bee2245e2 Mon Sep 17 00:00:00 2001 From: Sebastian Neuser Date: Fri, 20 Feb 2026 13:25:50 +0100 Subject: [PATCH 7/8] doc: Add documentation for GL3 migrations --- .../08-advanced/06-migrate-greenlight.md | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/docs/administration/08-advanced/06-migrate-greenlight.md b/docs/docs/administration/08-advanced/06-migrate-greenlight.md index 2f523595b..a9da3a3e1 100644 --- a/docs/docs/administration/08-advanced/06-migrate-greenlight.md +++ b/docs/docs/administration/08-advanced/06-migrate-greenlight.md @@ -1,6 +1,6 @@ --- -title: Migrate from Greenlight v2 -description: Step by step guide to migrate from Greenlight v2 to PILOS +title: Migrate from Greenlight +description: Step by step guide to migrate from Greenlight to PILOS --- PILOS provides an easy to use command to import all greenlight users (incl. ldap), rooms and shared accesses. @@ -26,8 +26,17 @@ ports: Also make sure the internal firewall of the OS and no external firewall is not blocking access to the port and from the host PILOS is running on. +If you want to import Room presentations, copy Greenlight's active storage directory to the PILOS app storage at `/storage/app/migration/presentations` and specify `--presentation-path=migration/presentations` at the command line. +Successfully imported presentation files will be moved to a different location. + ## Running migration command +The command will output the process of the import and imforms about failed user, room and shared access import. + +**Note** If a room with the same room id already exists in PILOS it will NOT be imported and the shared accesses are ignored. + +### Greenlight 2 + ```bash docker compose exec app pilos-cli import:greenlight-v2 {host : ip or hostname of postgres database server} {port : port of postgres database server} @@ -42,9 +51,40 @@ docker compose exec app pilos-cli import:greenlight-v2 {host : ip or hostname docker compose exec app pilos-cli import:greenlight-v2 localhost 5432 greenlight_production postgres 12345678 ``` -The command will output the process of the import and imforms about failed user, room and shared access import. +### Greenlight 3 -**Note** If a room with the same room id already exists in PILOS it will NOT be imported and the shared accesses are ignored. +``` +Usage: + import:greenlight-v3 [options] [--] + +Arguments: + host ip or hostname of postgres database server + port port of postgres database server + database greenlight database name, see greenlight .env variable DB_NAME + username greenlight database username, see greenlight .env variable DB_USERNAME + password greenlight database password, see greenlight .env variable DB_PASSWORD + +Options: + --no-confirm do not ask if the import should be committed + --default-role[=DEFAULT-ROLE] default role for imported external users + --room-type[=ROOM-TYPE] room type for imported rooms + --presentation-path[=PRESENTATION-PATH] path to room presentations +``` + +**Example** + +```bash +docker compose exec app pilos-cli import:greenlight-v3 \ + --no-confirm \ + --default-role=User \ + --room-type=Meeting \ + --presentation-path=migration/presentations \ + pg-cluster.svc.cluster.local + 5432 + greenlight-db + greenlight-user + d4t4basePa$$Word +``` ## Adjust nginx to redirect to PILOS (other host) From cd320d3ce7a825a269cab1b4bb34bcb8012ab789 Mon Sep 17 00:00:00 2001 From: Sebastian Neuser Date: Fri, 20 Feb 2026 13:26:01 +0100 Subject: [PATCH 8/8] doc: Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e191074e..142335412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Rate limiting to prevent Room-ID enumeration attacks ([#2518]) +- Greenlight3 import command ([#2664]) ### Changed