Http::response([ ['username' => 'alice', 'uid' => 1000, 'gid' => 1000, 'home_dir' => '/home/alice', 'shell' => '/bin/bash'], ]), 'http://node.test/groups' => Http::response([ ['groupname' => 'dev', 'gid' => 1000, 'members' => ['alice']], ]), 'http://node.test/users/alice/groups' => Http::response(['username' => 'alice', 'groups' => ['dev']]), ]); $admin = $this->admin(); $server = $this->server(); ServerUserBinding::query()->create([ 'user_id' => $admin->id, 'server_resource_id' => $server->id, 'username' => 'alice', 'remote_exists' => true, ]); $response = $this->actingAs($admin, 'api')->getJson('/servers/'.$server->id.'/system-users/meta'); $response ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.users.0.username', 'alice') ->assertJsonPath('data.groups.0.groupname', 'dev') ->assertJsonPath('data.user_groups.alice.0', 'dev'); } public function test_creating_user_can_create_remote_server_account_and_binding(): void { Http::preventStrayRequests(); Http::fake([ 'http://node.test/users/alice' => Http::response(['detail' => 'not found'], 404), 'http://node.test/users' => Http::response(['status' => 'ok', 'message' => 'User created.'], 201), ]); $admin = $this->admin(); $server = $this->server(); $response = $this->actingAs($admin, 'api')->postJson('/users', [ 'nickname' => 'Alice', 'email' => 'alice@example.com', 'phone' => '13800138000', 'password' => 'secret123', 'server_bindings' => [[ 'server_resource_id' => $server->id, 'username' => 'alice', 'create_remote' => true, 'groups' => [], ]], ]); $response->assertCreated()->assertJsonPath('code', 0); $user = User::query()->where('email', 'alice@example.com')->firstOrFail(); $this->assertDatabaseHas('server_user_bindings', [ 'user_id' => $user->id, 'server_resource_id' => $server->id, 'username' => 'alice', 'remote_exists' => true, 'force_password_change' => true, ]); Http::assertSent(function (Request $request): bool { return $request->method() === 'POST' && $request->url() === 'http://node.test/users' && $request['username'] === 'alice' && $request['groups'] === ['dev'] && $request['default_environment_variables'] === 'export A=1' && is_string($request['password_hash']) && str_starts_with($request['password_hash'], '$6$'); }); } public function test_binding_existing_remote_user_marks_password_change_required(): void { Http::preventStrayRequests(); Http::fake([ 'http://node.test/users/alice' => Http::response([ 'username' => 'alice', 'uid' => 1000, 'gid' => 1000, 'home_dir' => '/home/alice', 'shell' => '/bin/bash', ]), ]); $admin = $this->admin(); $server = $this->server(); $response = $this->actingAs($admin, 'api')->putJson('/users/'.$admin->id, [ 'server_bindings' => [[ 'server_resource_id' => $server->id, 'username' => 'alice', ]], ]); $response->assertOk()->assertJsonPath('code', 0); $this->assertDatabaseHas('server_user_bindings', [ 'user_id' => $admin->id, 'server_resource_id' => $server->id, 'username' => 'alice', 'remote_exists' => true, 'force_password_change' => true, ]); } public function test_unbinding_can_delete_remote_user(): void { Http::preventStrayRequests(); Http::fake([ 'http://node.test/users/alice' => Http::response(['status' => 'ok']), ]); $admin = $this->admin(); $server = $this->server(); ServerUserBinding::query()->create([ 'user_id' => $admin->id, 'server_resource_id' => $server->id, 'username' => 'alice', 'remote_exists' => true, 'force_password_change' => true, ]); $response = $this->actingAs($admin, 'api')->putJson('/users/'.$admin->id, [ 'server_unbindings' => [[ 'server_resource_id' => $server->id, 'username' => 'alice', 'delete_remote' => true, ]], ]); $response->assertOk()->assertJsonPath('code', 0); $this->assertDatabaseMissing('server_user_bindings', ['user_id' => $admin->id, 'server_resource_id' => $server->id]); Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' && $request->url() === 'http://node.test/users/alice'); } public function test_deleting_sso_user_does_not_delete_remote_server_user(): void { Http::fake(); $admin = $this->admin(); $server = $this->server(); $target = User::factory()->create(); ServerUserBinding::query()->create([ 'user_id' => $target->id, 'server_resource_id' => $server->id, 'username' => 'target', 'remote_exists' => true, ]); $response = $this->actingAs($admin, 'api')->deleteJson('/users/'.$target->id); $response->assertOk()->assertJsonPath('code', 0); $this->assertDatabaseMissing('server_user_bindings', ['user_id' => $target->id]); Http::assertNothingSent(); } public function test_server_list_includes_bound_username_without_exposing_token(): void { $admin = $this->admin(); $server = $this->server(); $resource = ServerResource::query()->create([ 'parent_id' => $server->id, 'name' => 'ssh', 'display_name' => 'SSH', 'internal_ip' => '10.0.0.10', 'asset_id' => 1, 'account_id' => 2, 'protocols' => ['SSH'], 'is_active' => true, ]); ServerUserBinding::query()->create([ 'user_id' => $admin->id, 'server_resource_id' => $server->id, 'username' => 'admin', 'remote_exists' => true, ]); $response = $this->actingAs($admin, 'api')->getJson('/servers'); $response ->assertOk() ->assertJsonPath('data.data.0.user_api_configured', true) ->assertJsonMissingPath('data.data.0.user_api_token'); $resourcePayload = collect($response->json('data.data'))->firstWhere('id', $resource->id); $this->assertSame('admin', $resourcePayload['server_username'] ?? null); } public function test_resource_use_requires_password_change_for_bound_account(): void { Http::fake(); $admin = $this->admin(); $server = $this->server(); $resource = ServerResource::query()->create([ 'parent_id' => $server->id, 'name' => 'ssh', 'display_name' => 'SSH', 'internal_ip' => '10.0.0.10', 'asset_id' => 1, 'account_id' => 2, 'protocols' => ['SSH'], 'is_active' => true, ]); ServerUserBinding::query()->create([ 'user_id' => $admin->id, 'server_resource_id' => $server->id, 'username' => 'alice', 'remote_exists' => true, 'force_password_change' => true, ]); $response = $this->actingAs($admin, 'api')->postJson('/servers/'.$resource->id.'/use', [ 'account_name' => 'alice', 'password' => 'secret', 'protocol' => 'SSH', ]); $response ->assertStatus(423) ->assertJsonPath('data.reason', 'server_password_change_required'); } public function test_bound_password_update_requires_strong_password_and_clears_flag(): void { Http::preventStrayRequests(); Http::fake([ 'http://node.test/users/alice/password' => Http::response(['status' => 'ok']), ]); $admin = $this->admin(); $server = $this->server(); ServerUserBinding::query()->create([ 'user_id' => $admin->id, 'server_resource_id' => $server->id, 'username' => 'alice', 'remote_exists' => true, 'force_password_change' => true, ]); $weak = $this->actingAs($admin, 'api')->patchJson('/servers/'.$server->id.'/bound-system-user/password', [ 'password' => 'abcdef', ]); $weak->assertStatus(422); $strong = $this->actingAs($admin, 'api')->patchJson('/servers/'.$server->id.'/bound-system-user/password', [ 'password' => 'Abc123!', ]); $strong->assertOk()->assertJsonPath('code', 0); $this->assertDatabaseHas('server_user_bindings', [ 'user_id' => $admin->id, 'server_resource_id' => $server->id, 'force_password_change' => false, ]); } public function test_server_environment_endpoints_proxy_to_user_management_api(): void { Http::preventStrayRequests(); Http::fake([ 'http://node.test/users/environment' => Http::response(['status' => 'ok', 'updated_count' => 2]), ]); $admin = $this->admin(); $server = $this->server(); $default = $this->actingAs($admin, 'api')->putJson('/servers/'.$server->id.'/default-environment', [ 'content' => 'export DEFAULT=1', ]); $default->assertOk()->assertJsonPath('data.default_environment_variables', 'export DEFAULT=1'); $all = $this->actingAs($admin, 'api')->putJson('/servers/'.$server->id.'/system-users/environment', [ 'content' => 'export ALL=1', ]); $all ->assertOk() ->assertJsonPath('data.updated_count', 2) ->assertJsonPath('data.all_user_environment_variables', 'export ALL=1'); $this->assertDatabaseHas('server_resources', [ 'id' => $server->id, 'all_user_environment_variables' => 'export ALL=1', ]); } public function test_user_management_api_error_message_is_extracted_from_detail_array(): void { Http::preventStrayRequests(); Http::fake([ 'http://node.test/users/alice/environment' => Http::response([ 'detail' => [ ['message' => 'permission denied'], ], ], 422), ]); $admin = $this->admin(); $server = $this->server(); $response = $this->actingAs($admin, 'api')->getJson('/servers/'.$server->id.'/system-users/alice/environment'); $response ->assertStatus(422) ->assertJsonPath('errors.server.0', 'permission denied'); } public function test_server_environment_endpoint_returns_partial_failures(): void { Http::preventStrayRequests(); Http::fake([ 'http://node.test/users/environment' => Http::response([ 'status' => 'ok', 'message' => 'User environments updated.', 'updated_users' => ['alice'], 'failed_users' => [ ['username' => 'bob', 'code' => 'system_permission_denied', 'message' => 'permission denied'], ], 'updated_count' => 1, 'failed_count' => 1, ]), ]); $admin = $this->admin(); $server = $this->server(); $response = $this->actingAs($admin, 'api')->putJson('/servers/'.$server->id.'/system-users/environment', [ 'content' => 'export ALL=1', ]); $response ->assertOk() ->assertJsonPath('data.updated_count', 1) ->assertJsonPath('data.failed_count', 1) ->assertJsonPath('data.failed_users.0.username', 'bob'); } public function test_default_user_groups_are_saved_and_used_when_creating_system_user(): void { Http::preventStrayRequests(); Http::fake([ 'http://node.test/users' => Http::response(['status' => 'ok', 'message' => 'User created.'], 201), ]); $admin = $this->admin(); $server = $this->server(); $save = $this->actingAs($admin, 'api')->putJson('/servers/'.$server->id.'/default-user-groups', [ 'groups' => ['dev', 'ops', 'dev'], ]); $save ->assertOk() ->assertJsonPath('data.default_user_groups.0', 'dev') ->assertJsonPath('data.default_user_groups.1', 'ops'); $create = $this->actingAs($admin, 'api')->postJson('/servers/'.$server->id.'/system-users', [ 'username' => 'alice', 'password' => 'secret123', 'groups' => ['extra'], ]); $create->assertCreated(); Http::assertSent(function (Request $request): bool { return $request->method() === 'POST' && $request->url() === 'http://node.test/users' && $request['username'] === 'alice' && $request['groups'] === ['dev', 'ops', 'extra']; }); } private function admin(): User { $user = User::factory()->create(); Role::query()->firstOrCreate(['name' => 'admin', 'guard_name' => 'api']); $user->assignRole('admin'); return $user; } private function server(): ServerResource { return ServerResource::query()->create([ 'name' => 'server01', 'display_name' => 'Server 01', 'internal_ip' => '10.0.0.10', 'user_api_base_url' => 'http://node.test', 'user_api_token' => 'secret-token', 'default_environment_variables' => 'export A=1', 'default_user_groups' => ['dev'], 'asset_id' => 1, 'account_id' => null, 'protocols' => [], 'is_active' => true, ]); } }