20640: Add computed permissions API.
authorTom Clegg <tom@curii.com>
Thu, 30 May 2024 15:23:16 +0000 (11:23 -0400)
committerTom Clegg <tom@curii.com>
Thu, 13 Jun 2024 20:57:11 +0000 (16:57 -0400)
Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@curii.com>

21 files changed:
lib/controller/federation/conn.go
lib/controller/router/router.go
lib/controller/rpc/conn.go
sdk/go/arvados/api.go
sdk/go/arvados/link.go
sdk/go/arvadostest/api.go
sdk/python/arvados-v1-discovery.json
sdk/python/tests/test_computed_permissions.py [new file with mode: 0644]
services/api/app/controllers/arvados/v1/computed_permissions_controller.rb [new file with mode: 0644]
services/api/app/controllers/arvados/v1/schema_controller.rb
services/api/app/models/arvados_model.rb
services/api/app/models/computed_permission.rb [new file with mode: 0644]
services/api/app/models/group.rb
services/api/app/models/materialized_permission.rb [deleted file]
services/api/app/models/user.rb
services/api/config/routes.rb
services/api/lib/can_be_an_owner.rb
services/api/lib/record_filters.rb
services/api/test/functional/arvados/v1/computed_permissions_controller_test.rb [new file with mode: 0644]
services/api/test/integration/computed_permissions_test.rb [new file with mode: 0644]
services/api/test/integration/discovery_document_test.rb

index c2cbfec008fc19d7c3615a1f73be512307629226..51f7a21c7dee2ff6575b8985ba0160ac4daaf59f 100644 (file)
@@ -408,6 +408,10 @@ func (conn *Conn) CollectionUntrash(ctx context.Context, options arvados.Untrash
        return conn.chooseBackend(options.UUID).CollectionUntrash(ctx, options)
 }
 
+func (conn *Conn) ComputedPermissionList(ctx context.Context, options arvados.ListOptions) (arvados.ComputedPermissionList, error) {
+       return conn.local.ComputedPermissionList(ctx, options)
+}
+
 func (conn *Conn) ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error) {
        return conn.generated_ContainerList(ctx, options)
 }
index 39c7d871d8e4116694242698f63fcc7ee24e68a3..5ac3cc7315880546802359a30277bf1b767b7b5c 100644 (file)
@@ -184,6 +184,13 @@ func (rtr *router) addRoutes() {
                                return rtr.backend.CollectionUntrash(ctx, *opts.(*arvados.UntrashOptions))
                        },
                },
+               {
+                       arvados.EndpointComputedPermissionList,
+                       func() interface{} { return &arvados.ListOptions{Limit: -1} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ComputedPermissionList(ctx, *opts.(*arvados.ListOptions))
+                       },
+               },
                {
                        arvados.EndpointContainerCreate,
                        func() interface{} { return &arvados.CreateOptions{} },
index 3125ae29be67ac57966088b6d0951c387c0c6b4e..899c5ce7dec996ad5ba18a5dddd453b0ca737e37 100644 (file)
@@ -341,6 +341,13 @@ func (conn *Conn) CollectionUntrash(ctx context.Context, options arvados.Untrash
        return resp, err
 }
 
+func (conn *Conn) ComputedPermissionList(ctx context.Context, options arvados.ListOptions) (arvados.ComputedPermissionList, error) {
+       ep := arvados.EndpointComputedPermissionList
+       var resp arvados.ComputedPermissionList
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
 func (conn *Conn) ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error) {
        ep := arvados.EndpointContainerCreate
        var resp arvados.Container
index d2e2b2088c64d247d1a8a656a1dc314baa313975..2c932531aed13efcb54689e54a555b6c537f8b51 100644 (file)
@@ -42,6 +42,7 @@ var (
        EndpointCollectionDelete                = APIEndpoint{"DELETE", "arvados/v1/collections/{uuid}", ""}
        EndpointCollectionTrash                 = APIEndpoint{"POST", "arvados/v1/collections/{uuid}/trash", ""}
        EndpointCollectionUntrash               = APIEndpoint{"POST", "arvados/v1/collections/{uuid}/untrash", ""}
+       EndpointComputedPermissionList          = APIEndpoint{"GET", "arvados/v1/computed_permissions", ""}
        EndpointContainerCreate                 = APIEndpoint{"POST", "arvados/v1/containers", "container"}
        EndpointContainerUpdate                 = APIEndpoint{"PATCH", "arvados/v1/containers/{uuid}", "container"}
        EndpointContainerPriorityUpdate         = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/update_priority", "container"}
@@ -291,6 +292,7 @@ type API interface {
        CollectionDelete(ctx context.Context, options DeleteOptions) (Collection, error)
        CollectionTrash(ctx context.Context, options DeleteOptions) (Collection, error)
        CollectionUntrash(ctx context.Context, options UntrashOptions) (Collection, error)
+       ComputedPermissionList(ctx context.Context, options ListOptions) (ComputedPermissionList, error)
        ContainerCreate(ctx context.Context, options CreateOptions) (Container, error)
        ContainerUpdate(ctx context.Context, options UpdateOptions) (Container, error)
        ContainerPriorityUpdate(ctx context.Context, options UpdateOptions) (Container, error)
index 7df6b84d60eb338fd833944940a2f966192960c2..3bf5bcb8dd48a0495e57326cc7159d36905a1082 100644 (file)
@@ -32,3 +32,15 @@ type LinkList struct {
        Offset         int    `json:"offset"`
        Limit          int    `json:"limit"`
 }
+
+type ComputedPermission struct {
+       UserUUID   string `json:"user_uuid"`
+       TargetUUID string `json:"target_uuid"`
+       PermLevel  string `json:"perm_level"`
+}
+
+type ComputedPermissionList struct {
+       Items          []ComputedPermission `json:"items"`
+       ItemsAvailable int                  `json:"items_available"`
+       Limit          int                  `json:"limit"`
+}
index 658874c6d71480f4afdda12c8b3a9db8ad6e55f3..5da69eb22cc41e77ac26f9df9b61cb3dc5b0f782 100644 (file)
@@ -108,6 +108,10 @@ func (as *APIStub) CollectionUntrash(ctx context.Context, options arvados.Untras
        as.appendCall(ctx, as.CollectionUntrash, options)
        return arvados.Collection{}, as.Error
 }
+func (as *APIStub) ComputedPermissionList(ctx context.Context, options arvados.ListOptions) (arvados.ComputedPermissionList, error) {
+       as.appendCall(ctx, as.ComputedPermissionList, options)
+       return arvados.ComputedPermissionList{}, as.Error
+}
 func (as *APIStub) ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error) {
        as.appendCall(ctx, as.ContainerCreate, options)
        return arvados.Container{}, as.Error
index b2392bf0a923d29c3d4c43d536e4b8691bfb7a0a..5f958cac7b0c6f196c9e73c8c8e2ac56d57a0307 100644 (file)
         }
       }
     },
+    "computed_permissions": {
+      "methods": {
+        "list": {
+          "id": "arvados.computed_permissions.list",
+          "path": "computed_permissions",
+          "httpMethod": "GET",
+          "description": "List ComputedPermissions.\n\n                   The <code>list</code> method returns a\n                   <a href=\"/api/resources.html\">resource list</a> of\n                   matching ComputedPermissions. For example:\n\n                   <pre>\n                   {\n                    \"kind\":\"arvados#computedPermissionList\",\n                    \"etag\":\"\",\n                    \"self_link\":\"\",\n                    \"next_page_token\":\"\",\n                    \"next_link\":\"\",\n                    \"items\":[\n                       ...\n                    ],\n                    \"items_available\":745,\n                    \"_profile\":{\n                     \"request_time\":0.157236317\n                    }\n                    </pre>",
+          "parameters": {
+            "filters": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "where": {
+              "type": "object",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "order": {
+              "type": "array",
+              "required": false,
+              "description": "",
+              "location": "query"
+            },
+            "select": {
+              "type": "array",
+              "description": "Attributes of each object to return in the response.",
+              "required": false,
+              "location": "query"
+            },
+            "distinct": {
+              "type": "boolean",
+              "required": false,
+              "default": "false",
+              "description": "",
+              "location": "query"
+            },
+            "limit": {
+              "type": "integer",
+              "required": false,
+              "default": "100",
+              "description": "",
+              "location": "query"
+            },
+            "count": {
+              "type": "string",
+              "required": false,
+              "default": "exact",
+              "description": "",
+              "location": "query"
+            }
+          },
+          "response": {
+            "$ref": "ComputedPermissionList"
+          },
+          "scopes": [
+            "https://api.arvados.org/auth/arvados",
+            "https://api.arvados.org/auth/arvados.readonly"
+          ]
+        }
+      }
+    },
     "containers": {
       "methods": {
         "get": {
         }
       }
     },
+    "ComputedPermissionList": {
+      "id": "ComputedPermissionList",
+      "description": "ComputedPermission list",
+      "type": "object",
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "Object type. Always arvados#computedPermissionList.",
+          "default": "arvados#computedPermissionList"
+        },
+        "etag": {
+          "type": "string",
+          "description": "List version."
+        },
+        "items": {
+          "type": "array",
+          "description": "The list of ComputedPermissions.",
+          "items": {
+            "$ref": "ComputedPermission"
+          }
+        },
+        "next_link": {
+          "type": "string",
+          "description": "A link to the next page of ComputedPermissions."
+        },
+        "next_page_token": {
+          "type": "string",
+          "description": "The page token for the next page of ComputedPermissions."
+        },
+        "selfLink": {
+          "type": "string",
+          "description": "A link back to this list."
+        }
+      }
+    },
+    "ComputedPermission": {
+      "id": "ComputedPermission",
+      "description": "ComputedPermission",
+      "type": "object",
+      "properties": {
+        "user_uuid": {
+          "type": "string"
+        },
+        "target_uuid": {
+          "type": "string"
+        },
+        "perm_level": {
+          "type": "integer"
+        }
+      }
+    },
     "ContainerList": {
       "id": "ContainerList",
       "description": "Container list",
diff --git a/sdk/python/tests/test_computed_permissions.py b/sdk/python/tests/test_computed_permissions.py
new file mode 100644 (file)
index 0000000..7f0eee2
--- /dev/null
@@ -0,0 +1,18 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import arvados
+from . import run_test_server
+
+class ComputedPermissionTest(run_test_server.TestCaseWithServers):
+    def test_computed_permission(self):
+        run_test_server.authorize_with('admin')
+        api_client = arvados.api('v1')
+        active_user_uuid = run_test_server.fixture('users')['active']['uuid']
+        resp = api_client.computed_permissions().list(
+            filters=[['user_uuid', '=', active_user_uuid]],
+        ).execute()
+        assert len(resp['items']) > 0
+        for item in resp['items']:
+            assert item['user_uuid'] == active_user_uuid
diff --git a/services/api/app/controllers/arvados/v1/computed_permissions_controller.rb b/services/api/app/controllers/arvados/v1/computed_permissions_controller.rb
new file mode 100644 (file)
index 0000000..9291a37
--- /dev/null
@@ -0,0 +1,7 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+class Arvados::V1::ComputedPermissionsController < ApplicationController
+  before_action :admin_required
+end
index f1184571573620867fa6e43e9223550e5ea1038d..2e61017393633521179ab6c56d211ce1a68d63bf 100644 (file)
@@ -397,6 +397,19 @@ class Arvados::V1::SchemaController < ApplicationController
       end
     end
 
+    # The computed_permissions controller does not offer all of the
+    # usual methods and attributes.  Modify discovery doc accordingly.
+    discovery[:resources]['computed_permissions'][:methods].select! do |method|
+      method == :list
+    end
+    discovery[:resources]['computed_permissions'][:methods][:list][:parameters].select! do |param|
+      ![:cluster_id, :bypass_federation, :offset].include?(param)
+    end
+    discovery[:schemas]['ComputedPermission'].delete(:uuidPrefix)
+    discovery[:schemas]['ComputedPermission'][:properties].select! do |prop|
+      ![:uuid, :etag].include?(prop)
+    end
+
     # The 'replace_files' option is implemented in lib/controller,
     # not Rails -- we just need to add it here so discovery-aware
     # clients know how to validate it.
index 573ea9d08b94b135f4cc4f289497a75d403d6af9..bf7a2ad863350d6e1985ab1b03b5427d72c19e1a 100644 (file)
@@ -170,10 +170,6 @@ class ArvadosModel < ApplicationRecord
     end.map(&:name)
   end
 
-  def self.attribute_column attr
-    self.columns.select { |col| col.name == attr.to_s }.first
-  end
-
   def self.attributes_required_columns
     # This method returns a hash.  Each key is the name of an API attribute,
     # and it's mapped to a list of database columns that must be fetched
@@ -565,18 +561,6 @@ class ArvadosModel < ApplicationRecord
     "to_tsvector('english', substr(#{parts.join(" || ' ' || ")}, 0, 8000))"
   end
 
-  def self.apply_filters query, filters
-    ft = record_filters filters, self
-    if not ft[:cond_out].any?
-      return query
-    end
-    ft[:joins].each do |t|
-      query = query.joins(t)
-    end
-    query.where('(' + ft[:cond_out].join(') AND (') + ')',
-                          *ft[:param_out])
-  end
-
   @_add_uuid_to_name = false
   def add_uuid_to_make_unique_name
     @_add_uuid_to_name = true
diff --git a/services/api/app/models/computed_permission.rb b/services/api/app/models/computed_permission.rb
new file mode 100644 (file)
index 0000000..af89ead
--- /dev/null
@@ -0,0 +1,58 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+require 'record_filters'
+
+class ComputedPermission < ApplicationRecord
+  self.table_name = 'materialized_permissions'
+  include CurrentApiClient
+  include CommonApiTemplate
+  extend RecordFilters
+
+  PERM_LEVEL_S = ['none', 'can_read', 'can_write', 'can_manage']
+
+  api_accessible :user do |t|
+    t.add :user_uuid
+    t.add :target_uuid
+    t.add :perm_level_s, as: :perm_level
+  end
+
+  protected
+
+  def perm_level_s
+    PERM_LEVEL_S[perm_level]
+  end
+
+  def self.default_orders
+    ["#{table_name}.user_uuid", "#{table_name}.target_uuid"]
+  end
+
+  def self.readable_by(*args)
+    self
+  end
+
+  def self.searchable_columns(operator)
+    if !operator.match(/[<=>]/) && !operator.in?(['in', 'not in'])
+      []
+    else
+      ['user_uuid', 'target_uuid']
+    end
+  end
+
+  def self.limit_index_columns_read
+    []
+  end
+
+  def self.selectable_attributes
+    %w(user_uuid target_uuid perm_level)
+  end
+
+  def self.columns_for_attributes(select_attributes)
+    select_attributes
+  end
+
+  def self.serialized_attributes
+    {}
+  end
+end
index d4c81fe9d1d9cf2c558d644bf45bf2f495dc9ef6..d159b73c94d7b3dde5bf5fa1560d2fa636845da3 100644 (file)
@@ -231,7 +231,7 @@ insert into frozen_groups (uuid) select uuid from temptable where is_frozen on c
 
   def before_ownership_change
     if owner_uuid_changed? and !self.owner_uuid_was.nil?
-      MaterializedPermission.where(user_uuid: owner_uuid_was, target_uuid: uuid).delete_all
+      ComputedPermission.where(user_uuid: owner_uuid_was, target_uuid: uuid).delete_all
       update_permissions self.owner_uuid_was, self.uuid, REVOKE_PERM
     end
   end
@@ -243,7 +243,7 @@ insert into frozen_groups (uuid) select uuid from temptable where is_frozen on c
   end
 
   def clear_permissions_trash_frozen
-    MaterializedPermission.where(target_uuid: uuid).delete_all
+    ComputedPermission.where(target_uuid: uuid).delete_all
     ActiveRecord::Base.connection.exec_delete(
       "delete from trashed_groups where group_uuid=$1",
       "Group.clear_permissions_trash_frozen",
diff --git a/services/api/app/models/materialized_permission.rb b/services/api/app/models/materialized_permission.rb
deleted file mode 100644 (file)
index 24ba673..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-class MaterializedPermission < ApplicationRecord
-end
index 299b20baa6b3ac3a77b3b7e048e723da64d9ece9..3fc71abf2d221ae0242d19f79913c0345e7493b3 100644 (file)
@@ -171,7 +171,7 @@ SELECT 1 FROM #{PERMISSION_VIEW}
 
   def before_ownership_change
     if owner_uuid_changed? and !self.owner_uuid_was.nil?
-      MaterializedPermission.where(user_uuid: owner_uuid_was, target_uuid: uuid).delete_all
+      ComputedPermission.where(user_uuid: owner_uuid_was, target_uuid: uuid).delete_all
       update_permissions self.owner_uuid_was, self.uuid, REVOKE_PERM
     end
   end
@@ -183,7 +183,7 @@ SELECT 1 FROM #{PERMISSION_VIEW}
   end
 
   def clear_permissions
-    MaterializedPermission.where("user_uuid = ? and target_uuid != ?", uuid, uuid).delete_all
+    ComputedPermission.where("user_uuid = ? and target_uuid != ?", uuid, uuid).delete_all
   end
 
   def forget_cached_group_perms
@@ -191,7 +191,7 @@ SELECT 1 FROM #{PERMISSION_VIEW}
   end
 
   def remove_self_from_permissions
-    MaterializedPermission.where("target_uuid = ?", uuid).delete_all
+    ComputedPermission.where("target_uuid = ?", uuid).delete_all
     check_permissions_against_full_refresh
   end
 
index df3c057b5758f0deb76420ba7a2f6908edd199d9..910e6a3f292f6b5ba017fd7ba45fb364f91e968c 100644 (file)
@@ -50,7 +50,6 @@ Rails.application.routes.draw do
       end
       resources :links
       resources :logs
-      resources :workflows
       resources :user_agreements do
         get 'signatures', on: :collection
         post 'sign', on: :collection
@@ -68,6 +67,8 @@ Rails.application.routes.draw do
         get 'logins', on: :member
         get 'get_all_logins', on: :collection
       end
+      resources :workflows
+      get '/computed_permissions', to: 'computed_permissions#index'
       get '/permissions/:uuid', to: 'links#get_permissions'
     end
   end
index 995f6f334c3ab48e7b82a14755ad0dcde7bb58ac..f2d4d7c051d5f45ed205eef0487dac2a9de2bd29 100644 (file)
@@ -24,6 +24,7 @@ module CanBeAnOwner
                       'jobs',
                       'job_tasks',
                       'keep_disks',
+                      'materialized_permissions',
                       'nodes',
                       'pipeline_instances',
                       'pipeline_templates',
index e51223254f7d21a34b5910e2f68a59abee4c22cb..41a920167740ed066bdb9491a23ab23cf4bd44f3 100644 (file)
@@ -293,4 +293,19 @@ module RecordFilters
     {:cond_out => conds_out, :param_out => param_out, :joins => joins}
   end
 
+  def apply_filters query, filters
+    ft = record_filters filters, self
+    if not ft[:cond_out].any?
+      return query
+    end
+    ft[:joins].each do |t|
+      query = query.joins(t)
+    end
+    query.where('(' + ft[:cond_out].join(') AND (') + ')',
+                          *ft[:param_out])
+  end
+
+  def attribute_column attr
+    self.columns.select { |col| col.name == attr.to_s }.first
+  end
 end
diff --git a/services/api/test/functional/arvados/v1/computed_permissions_controller_test.rb b/services/api/test/functional/arvados/v1/computed_permissions_controller_test.rb
new file mode 100644 (file)
index 0000000..6c89e90
--- /dev/null
@@ -0,0 +1,90 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+require 'test_helper'
+
+class Arvados::V1::ComputedPermissionsControllerTest < ActionController::TestCase
+  test "require auth" do
+    get :index, params: {}
+    assert_response 401
+  end
+
+  test "require admin" do
+    authorize_with :active
+    get :index, params: {}
+    assert_response 403
+  end
+
+  test "index with no options" do
+    authorize_with :admin
+    get :index, params: {}
+    assert_response :success
+    assert_operator 0, :<, json_response['items'].length
+
+    last_user = ''
+    last_target = ''
+    json_response['items'].each do |item|
+      assert_not_empty item['user_uuid']
+      assert_not_empty item['target_uuid']
+      assert_not_empty item['perm_level']
+      # check default ordering
+      assert_operator last_user, :<=, item['user_uuid']
+      if last_user == item['user_uuid']
+        assert_operator last_target, :<=, item['target_uuid']
+      end
+      last_user = item['user_uuid']
+      last_target = item['target_uuid']
+    end
+  end
+
+  test "index with limit" do
+    authorize_with :admin
+    get :index, params: {limit: 10}
+    assert_response :success
+    assert_equal 10, json_response['items'].length
+  end
+
+  test "index with filter on user_uuid" do
+    user_uuid = users(:active).uuid
+    authorize_with :admin
+    get :index, params: {filters: [['user_uuid', '=', user_uuid]]}
+    assert_response :success
+    assert_not_equal 0, json_response['items'].length
+    json_response['items'].each do |item|
+      assert_equal user_uuid, item['user_uuid']
+    end
+  end
+
+  test "index with filter on user_uuid and target_uuid" do
+    user_uuid = users(:active).uuid
+    target_uuid = groups(:aproject).uuid
+    authorize_with :admin
+    get :index, params: {filters: [
+                           ['user_uuid', '=', user_uuid],
+                           ['target_uuid', '=', target_uuid],
+                         ]}
+    assert_response :success
+    assert_equal([{"user_uuid" => user_uuid,
+                   "target_uuid" => target_uuid,
+                   "perm_level" => "can_manage",
+                  }],
+                 json_response['items'])
+  end
+
+  test "index with disallowed filters" do
+    authorize_with :admin
+    get :index, params: {filters: [['perm_level', '=', 'can_manage']]}
+    assert_response 422
+  end
+
+  %w(user_uuid target_uuid perm_level).each do |attr|
+    test "select only #{attr}" do
+      authorize_with :admin
+      get :index, params: {select: [attr], limit: 1}
+      assert_response :success
+      assert_operator 0, :<, json_response['items'][0][attr].length
+      assert_equal([{attr => json_response['items'][0][attr]}], json_response['items'])
+    end
+  end
+end
diff --git a/services/api/test/integration/computed_permissions_test.rb b/services/api/test/integration/computed_permissions_test.rb
new file mode 100644 (file)
index 0000000..803c7fe
--- /dev/null
@@ -0,0 +1,28 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+require 'test_helper'
+
+class ComputedPermissionsTest < ActionDispatch::IntegrationTest
+  include DbCurrentTime
+  fixtures :users, :groups, :api_client_authorizations, :collections
+
+  test "non-admin forbidden" do
+    get "/arvados/v1/computed_permissions",
+      params: {:format => :json},
+      headers: auth(:active)
+    assert_response 403
+  end
+
+  test "admin get permission for specified user" do
+    get "/arvados/v1/computed_permissions",
+      params: {
+        :format => :json,
+        :filters => [['user_uuid', '=', users(:active).uuid]].to_json,
+      },
+      headers: auth(:admin)
+    assert_response :success
+    assert_equal users(:active).uuid, json_response['items'][0]['user_uuid']
+  end
+end
index 37e775029733be80bcf101f8f544da626c985372..e29c4416b359efbdfc92756708e3643350dc847b 100644 (file)
@@ -49,10 +49,9 @@ class DiscoveryDocumentTest < ActionDispatch::IntegrationTest
     if expected_json != actual_json
       File.open(out_path, "w") { |f| f.write(actual_json) }
     end
-    assert_equal(expected_json, actual_json, [
-                   "#{src_path} did not match the live discovery document",
-                   "Current live version saved to #{out_path}",
-                   "Commit that to #{src_path} to regenerate documentation",
-                 ].join(". "))
+    assert_equal(expected_json, actual_json,
+                 "Live discovery document did not match the expected version (#{src_path}). " +
+                 "If the live version is correct, copy it to the git working directory by running:\n" +
+                 "cp #{out_path} #{src_path}\n")
   end
 end