originalDatabaseConfig = [ 'default' => config('database.default'), 'testing_database' => config('database.connections.testing.database'), ]; $this->originalDatabaseEnvironment = [ 'DB_CONNECTION' => [ 'env' => $_ENV['DB_CONNECTION'] ?? null, 'server' => $_SERVER['DB_CONNECTION'] ?? null, 'process' => getenv('DB_CONNECTION') === false ? null : getenv('DB_CONNECTION'), ], 'DB_DATABASE' => [ 'env' => $_ENV['DB_DATABASE'] ?? null, 'server' => $_SERVER['DB_DATABASE'] ?? null, 'process' => getenv('DB_DATABASE') === false ? null : getenv('DB_DATABASE'), ], ]; $this->databasePath = storage_path('framework/testing-mapledeploy-user-mgmt-'.bin2hex(random_bytes(6)).'.sqlite'); touch($this->databasePath); config([ 'database.default' => 'testing', 'database.connections.testing.database' => $this->databasePath, ]); $_ENV['DB_CONNECTION'] = 'testing'; $_SERVER['DB_CONNECTION'] = 'testing'; $_ENV['DB_DATABASE'] = $this->databasePath; $_SERVER['DB_DATABASE'] = $this->databasePath; putenv('DB_CONNECTION=testing'); putenv("DB_DATABASE={$this->databasePath}"); $GLOBALS['mapledeployUserMgmtDatabasePath'] = $this->databasePath; DB::purge('testing'); DB::reconnect('testing'); Artisan::call('migrate:fresh', ['--database' => 'testing']); InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); }); afterEach(function () { DB::disconnect('testing'); DB::purge('testing'); config([ 'database.default' => $this->originalDatabaseConfig['default'] ?? null, 'database.connections.testing.database' => $this->originalDatabaseConfig['testing_database'] ?? null, ]); if (isset($this->databasePath) && file_exists($this->databasePath)) { unlink($this->databasePath); } foreach (($this->originalDatabaseEnvironment ?? []) as $key => $values) { if ($values['env'] === null) { unset($_ENV[$key]); } else { $_ENV[$key] = $values['env']; } if ($values['server'] === null) { unset($_SERVER[$key]); } else { $_SERVER[$key] = $values['server']; } if ($values['process'] === null) { putenv($key); } else { putenv("{$key}={$values['process']}"); } } unset($GLOBALS['mapledeployUserMgmtDatabasePath']); }); function runMapledeployUserCommand(array $arguments, string $stdin = ''): array { $process = new Process( [PHP_BINARY, 'artisan', ...$arguments], base_path(), [ 'APP_ENV' => 'testing', 'APP_KEY' => config('app.key'), 'DB_CONNECTION' => 'testing', 'DB_DATABASE' => $GLOBALS['mapledeployUserMgmtDatabasePath'], 'CACHE_DRIVER' => 'array', 'SESSION_DRIVER' => 'database', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array', 'SELF_HOSTED' => 'true', ], ); $process->setInput($stdin); $process->setTimeout(30); $process->run(); $jsonLine = collect(explode("\n", $process->getOutput())) ->map(fn (string $line) => trim($line)) ->first(fn (string $line) => str_starts_with($line, '{') && str_ends_with($line, '}')); return [ 'exitCode' => $process->getExitCode(), 'json' => $jsonLine ? json_decode($jsonLine, true, flags: JSON_THROW_ON_ERROR) : null, 'stdout' => $process->getOutput(), 'stderr' => $process->getErrorOutput(), ]; } test('MapleDeploy user management commands create, list, reset, and revoke users', function () { $admin = runMapledeployUserCommand([ 'mapledeploy:user:create', '--admin', '--email=Owner@Example.com', '--name=Owner', ], "owner-password\n"); expect($admin['exitCode'])->toBe(0) ->and($admin['json']['user'])->toMatchArray([ 'id' => 0, 'email' => 'owner@example.com', 'name' => 'Owner', ]) ->and((bool) InstanceSettings::findOrFail(0)->is_registration_enabled)->toBeFalse() ->and(Hash::check('owner-password', User::findOrFail(0)->password))->toBeTrue(); $duplicateOwner = runMapledeployUserCommand([ 'mapledeploy:user:create', '--email=OWNER@Example.com', '--name=Duplicate Owner', ], "duplicate-password\n"); expect($duplicateOwner['exitCode'])->toBe(1) ->and($duplicateOwner['json'])->toBe(['error' => 'EMAIL_EXISTS']); $member = runMapledeployUserCommand([ 'mapledeploy:user:create', '--email=Member@Example.com', '--name=Member', '--team-role=admin', ], "member-password\n"); expect($member['exitCode'])->toBe(0) ->and($member['json']['user']['email'])->toBe('member@example.com'); $memberUser = User::whereEmail('member@example.com')->firstOrFail(); expect($memberUser->teams()->where('teams.id', 0)->first()?->pivot?->role)->toBe('admin'); $list = runMapledeployUserCommand(['mapledeploy:user:list']); expect($list['exitCode'])->toBe(0) ->and(collect($list['json']['users'])->pluck('email')->all()) ->toBe(['owner@example.com', 'member@example.com']); $resetOtherUser = User::factory()->create(); DB::table('sessions')->insert([ [ 'id' => 'member-reset-session', 'user_id' => $memberUser->id, 'ip_address' => '127.0.0.1', 'user_agent' => 'Test Browser', 'payload' => base64_encode('member-reset-payload'), 'last_activity' => now()->timestamp, ], [ 'id' => 'other-reset-session', 'user_id' => $resetOtherUser->id, 'ip_address' => '127.0.0.1', 'user_agent' => 'Test Browser', 'payload' => base64_encode('other-reset-payload'), 'last_activity' => now()->timestamp, ], ]); $reset = runMapledeployUserCommand([ 'mapledeploy:user:set-password', (string) $memberUser->id, ], "new-member-password\n"); expect($reset['exitCode'])->toBe(0) ->and(Hash::check('new-member-password', $memberUser->fresh()->password))->toBeTrue(); expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0) ->and(DB::table('sessions')->where('user_id', $resetOtherUser->id)->count())->toBe(1); DB::table('personal_access_tokens')->insert([ 'tokenable_type' => User::class, 'tokenable_id' => $memberUser->id, 'name' => 'e2e-token', 'token' => hash('sha256', 'e2e-token'), 'team_id' => '0', 'abilities' => json_encode(['*'], JSON_THROW_ON_ERROR), 'created_at' => now(), 'updated_at' => now(), ]); expect($memberUser->tokens()->count())->toBe(1); $otherUser = User::factory()->create(); DB::table('sessions')->insert([ [ 'id' => 'member-session', 'user_id' => $memberUser->id, 'ip_address' => '127.0.0.1', 'user_agent' => 'Test Browser', 'payload' => base64_encode('member-payload'), 'last_activity' => now()->timestamp, ], [ 'id' => 'other-session', 'user_id' => $otherUser->id, 'ip_address' => '127.0.0.1', 'user_agent' => 'Test Browser', 'payload' => base64_encode('other-payload'), 'last_activity' => now()->timestamp, ], ]); expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(1); $revokeRoot = runMapledeployUserCommand(['mapledeploy:user:revoke', '0']); expect($revokeRoot['exitCode'])->toBe(1) ->and($revokeRoot['json'])->toBe(['error' => 'CANNOT_REVOKE_ROOT_USER']); $revoke = runMapledeployUserCommand([ 'mapledeploy:user:revoke', (string) $memberUser->id, ]); expect($revoke['exitCode'])->toBe(0) ->and($revoke['json']['revoked']['email'])->toBe('member@example.com') ->and($memberUser->fresh()->tokens()->count())->toBe(0); expect(str_starts_with((string) $memberUser->fresh()->remember_token, 'mapledeploy-revoked:'))->toBeTrue(); expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0) ->and(DB::table('sessions')->where('user_id', $otherUser->id)->count())->toBe(1); $restore = runMapledeployUserCommand([ 'mapledeploy:user:set-password', (string) $memberUser->id, ], "restored-member-password\n"); expect($restore['exitCode'])->toBe(0) ->and(Hash::check('restored-member-password', $memberUser->fresh()->password))->toBeTrue() ->and($memberUser->fresh()->remember_token)->toBeNull(); }); test('MapleDeploy password command can transfer root ownership identity', function () { runMapledeployUserCommand([ 'mapledeploy:user:create', '--admin', '--email=old-owner@example.com', '--name=Old Owner', ], "old-owner-password\n"); DB::table('sessions')->insert([ 'id' => 'root-session', 'user_id' => 0, 'ip_address' => '127.0.0.1', 'user_agent' => 'Test Browser', 'payload' => base64_encode('root-payload'), 'last_activity' => now()->timestamp, ]); $claim = runMapledeployUserCommand([ 'mapledeploy:user:set-password', '0', '--email=New.Owner@Example.com', '--name=New Owner', ], "new-owner-password\n"); $root = User::findOrFail(0); expect($claim['exitCode'])->toBe(0) ->and($claim['json']['user'])->toMatchArray([ 'id' => 0, 'email' => 'new.owner@example.com', 'name' => 'New Owner', ]) ->and($root->email)->toBe('new.owner@example.com') ->and($root->name)->toBe('New Owner') ->and(Hash::check('new-owner-password', $root->password))->toBeTrue() ->and($root->remember_token)->toBeNull() ->and($root->email_verified_at)->not->toBeNull(); expect(User::whereEmail('old-owner@example.com')->exists())->toBeFalse(); expect(DB::table('sessions')->where('user_id', 0)->count())->toBe(0); }); test('MapleDeploy password command rejects ownership transfer to an existing email', function () { runMapledeployUserCommand([ 'mapledeploy:user:create', '--admin', '--email=old-owner@example.com', '--name=Old Owner', ], "old-owner-password\n"); $existing = User::factory()->create(['email' => 'new.owner@example.com']); $claim = runMapledeployUserCommand([ 'mapledeploy:user:set-password', '0', '--email=new.owner@example.com', '--name=New Owner', ], "new-owner-password\n"); expect($claim['exitCode'])->toBe(1) ->and($claim['json'])->toBe(['error' => 'EMAIL_EXISTS']) ->and(User::findOrFail(0)->email)->toBe('old-owner@example.com') ->and($existing->fresh()->email)->toBe('new.owner@example.com'); }); test('MapleDeploy user creation command reports validation errors as JSON', function () { $invalid = runMapledeployUserCommand([ 'mapledeploy:user:create', '--email=not-an-email', '--name=Invalid', ], "short\n"); expect($invalid['exitCode'])->toBe(1) ->and($invalid['json'])->toBe(['error' => 'INVALID_INPUT']); });