def render_editable_attribute(object, attr, attrvalue=nil, htmloptions={})
attrvalue = object.send(attr) if attrvalue.nil?
- if !object.attribute_editable?(attr, :ever) or
- (!object.editable? and
- !object.owner_uuid.in?(my_projects.collect(&:uuid)))
+ if not object.attribute_editable?(attr)
if attrvalue && attrvalue.length > 0
return render_attribute_as_textile( object, attr, attrvalue, false )
else
preconfigured_search_str = value_info[:search_for]
end
- if !object or
- !object.attribute_editable?(attr, :ever) or
- (!object.editable? and
- !object.owner_uuid.in?(my_projects.collect(&:uuid)))
+ if not object.andand.attribute_editable?(attr)
return link_to_if_arvados_object attrvalue
end
class ApiClientAuthorization < ArvadosBase
- def attribute_editable? attr, *args
- ['expires_at', 'default_owner_uuid'].index attr
+ def editable_attributes
+ %w(expires_at default_owner_uuid)
end
def self.creatable?
false
(current_user.is_admin or
current_user.uuid == self.owner_uuid or
new_record? or
- (writable_by.include? current_user.uuid rescue false))) or false
+ (respond_to?(:writable_by) ?
+ writable_by.include?(current_user.uuid) :
+ (ArvadosBase.find(owner_uuid).writable_by.include? current_user.uuid rescue false)))) or false
+ end
+
+ # Array of strings that are the names of attributes that can be edited
+ # with X-Editable.
+ def editable_attributes
+ self.class.columns.map(&:name) -
+ %w(created_at modified_at modified_by_user_uuid modified_by_client_uuid updated_at)
end
def attribute_editable?(attr, ever=nil)
- if %w(created_at modified_at modified_by_user_uuid modified_by_client_uuid updated_at).include? attr.to_s
+ if not editable_attributes.include?(attr.to_s)
false
elsif not (current_user.andand.is_active)
false
class AuthorizedKey < ArvadosBase
- def attribute_editable? attr, *args
- if attr.to_s == 'authorized_user_uuid'
- current_user and current_user.is_admin
+ def attribute_editable?(attr, ever=nil)
+ if (attr.to_s == 'authorized_user_uuid') and (not ever)
+ current_user.andand.is_admin
else
super
end
dir_to_tree.call('.')
end
- def attribute_editable? attr, *args
- if %w(name description manifest_text).include? attr.to_s
- true
- else
- super
- end
+ def editable_attributes
+ %w(name description manifest_text)
end
def self.creatable?
"#{script} job"
end
- def attribute_editable? attr, *args
- if attr.to_sym == :description
- super && attr.to_sym == :description
- else
- false
- end
+ def editable_attributes
+ %w(description)
end
def self.creatable?
arvados_api_client.api("jobs/", "queue_size", {"_method"=> "GET"})[:queue_size] rescue 0
end
- def self.queue
+ def self.queue
arvados_api_client.unpack_api_response arvados_api_client.api("jobs/", "queue", {"_method"=> "GET"})
end
end
end
- def attribute_editable? attr, *args
- super && (attr.to_sym == :name || attr.to_sym == :description ||
- (attr.to_sym == :components and
- (self.state == 'New' || self.state == 'Ready')))
+ def editable_attributes
+ %w(name description components)
+ end
+
+ def attribute_editable?(name, ever=nil)
+ (ever or %w(New Ready).include?(state)) and super
end
def attributes_for_display
super.reject { |k,v| %w(owner_uuid default_owner_uuid identity_url prefs).index k }
end
- def attribute_editable? attr, *args
- (not (self.uuid.andand.match(/000000000000000$/) and self.is_admin)) and super
+ def attribute_editable?(attr, ever=nil)
+ (ever or not (self.uuid.andand.match(/000000000000000$/) and
+ self.is_admin)) and super
end
def friendly_link_name lookup=nil
def attributes_for_display
super.append ['current_user_logins', @current_user_logins]
end
- def attribute_editable? attr, *args
- attr != 'current_user_logins' and super
+ def editable_attributes
+ super - %w(current_user_logins)
end
def self.attribute_info
merger = ->(k,a,b) { a.merge(b, &merger) }
refute user_can_manage(:project_viewer, "asubproject")
end
+ test "subproject_admin can_manage asubproject" do
+ assert user_can_manage(:subproject_admin, "asubproject")
+ end
+
+ test "project admin can remove items from the project" do
+ coll_key = "collection_to_remove_from_subproject"
+ coll_uuid = api_fixture("collections")[coll_key]["uuid"]
+ delete(:remove_item,
+ { id: api_fixture("groups")["asubproject"]["uuid"],
+ item_uuid: coll_uuid,
+ format: "js" },
+ session_for(:subproject_admin))
+ assert_response :success
+ assert_match(/\b#{coll_uuid}\b/, @response.body,
+ "removed object not named in response")
+ end
+
test 'projects#show tab infinite scroll partial obeys limit' do
get_contents_rows(limit: 1, filters: [['uuid','is_a',['arvados#job']]])
assert_response :success
teardown do
Thread.current[:arvados_api_token] = nil
+ Thread.current[:user] = nil
Thread.current[:reader_tokens] = nil
+ Rails.cache.clear
# Restore configuration settings changed during tests
$application_config.each do |k,v|
if k.match /^[^.]*$/
module ClassMethods
@@api_fixtures = {}
- def api_fixture(name)
+ def api_fixture(name, *keys)
# Returns the data structure from the named API server test fixture.
@@api_fixtures[name] ||= \
begin
file = file[0, trim_index] if trim_index
YAML.load(file)
end
+ keys.inject(@@api_fixtures[name]) { |hash, key| hash[key] }
end
end
- def api_fixture name
- self.class.api_fixture name
+ def api_fixture(name, *keys)
+ self.class.api_fixture(name, *keys)
+ end
+
+ def find_fixture(object_class, name)
+ object_class.find(api_fixture(object_class.to_s.pluralize.underscore,
+ name, "uuid"))
end
end
get_files_tree('multilevel_collection_2'),
"Collection file tree was malformed")
end
+
+ test "portable_data_hash never editable" do
+ refute(Collection.new.attribute_editable?("portable_data_hash", :ever))
+ end
+
+ test "admin can edit name" do
+ use_token :admin
+ assert(find_fixture(Collection, "foo_file").attribute_editable?("name"),
+ "admin not allowed to edit collection name")
+ end
+
+ test "project owner can edit name" do
+ use_token :active
+ assert(find_fixture(Collection, "foo_collection_in_aproject")
+ .attribute_editable?("name"),
+ "project owner not allowed to edit collection name")
+ end
+
+ test "project admin can edit name" do
+ use_token :subproject_admin
+ assert(find_fixture(Collection, "baz_file_in_asubproject")
+ .attribute_editable?("name"),
+ "project admin not allowed to edit collection name")
+ end
+
+ test "project viewer cannot edit name" do
+ use_token :project_viewer
+ refute(find_fixture(Collection, "foo_collection_in_aproject")
+ .attribute_editable?("name"),
+ "project viewer allowed to edit collection name")
+ end
end
assert_nil user.owner_uuid
end
end
+
+ test "project editable by its admin" do
+ use_token :subproject_admin
+ project = Group.find(api_fixture("groups")["asubproject"]["uuid"])
+ assert(project.editable?, "project not editable by admin")
+ end
+
+ test "project not editable by reader" do
+ use_token :project_viewer
+ project = Group.find(api_fixture("groups")["aproject"]["uuid"])
+ refute(project.editable?, "project editable by reader")
+ end
end
require 'test_helper'
class JobTest < ActiveSupport::TestCase
- # test "the truth" do
- # assert true
- # end
+ test "admin can edit description" do
+ use_token :admin
+ assert(find_fixture(Job, "job_in_subproject")
+ .attribute_editable?("description"),
+ "admin not allowed to edit job description")
+ end
+
+ test "project owner can edit description" do
+ use_token :active
+ assert(find_fixture(Job, "job_in_subproject")
+ .attribute_editable?("description"),
+ "project owner not allowed to edit job description")
+ end
+
+ test "project admin can edit description" do
+ use_token :subproject_admin
+ assert(find_fixture(Job, "job_in_subproject")
+ .attribute_editable?("description"),
+ "project admin not allowed to edit job description")
+ end
+
+ test "project viewer cannot edit description" do
+ use_token :project_viewer
+ refute(find_fixture(Job, "job_in_subproject")
+ .attribute_editable?("description"),
+ "project viewer allowed to edit job description")
+ end
end
require 'test_helper'
class PipelineInstanceTest < ActiveSupport::TestCase
- # test "the truth" do
- # assert true
- # end
+ test "admin can edit name" do
+ use_token :admin
+ assert(find_fixture(PipelineInstance, "new_pipeline_in_subproject")
+ .attribute_editable?("name"),
+ "admin not allowed to edit pipeline instance name")
+ end
+
+ test "project owner can edit name" do
+ use_token :active
+ assert(find_fixture(PipelineInstance, "new_pipeline_in_subproject")
+ .attribute_editable?("name"),
+ "project owner not allowed to edit pipeline instance name")
+ end
+
+ test "project admin can edit name" do
+ use_token :subproject_admin
+ assert(find_fixture(PipelineInstance, "new_pipeline_in_subproject")
+ .attribute_editable?("name"),
+ "project admin not allowed to edit pipeline instance name")
+ end
+
+ test "project viewer cannot edit name" do
+ use_token :project_viewer
+ refute(find_fixture(PipelineInstance, "new_pipeline_in_subproject")
+ .attribute_editable?("name"),
+ "project viewer allowed to edit pipeline instance name")
+ end
end
|group_class|string|Type of group. This does not affect behavior, but determines how the group is presented in the user interface. For example, @project@ indicates that the group should be displayed by Workbench and arv-mount as a project for organizing and naming objects.|@"project"@
null|
|description|text|||
+|writable_by|array|List of UUID strings identifying Users and other Groups that have write permission for this Group. Only users who are allowed to administer the Group will receive a full list. Other users will receive a partial list that includes the Group's owner_uuid and (if applicable) their own user UUID.||
|prefs|hash|||
|default_owner_uuid|string|||
|is_active|boolean|||
+|writable_by|array|List of UUID strings identifying Groups and other Users that can modify this User object. This will include the user's owner_uuid and, for administrators and users requesting their own User object, the requesting user's UUID.||
--- /dev/null
+#!/usr/bin/env python
+
+import sys
+import logging
+import argparse
+import arvados
+import json
+from arvados.events import subscribe
+import signal
+
+def main(arguments=None):
+ logger = logging.getLogger('arvados.arv-ws')
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-u', '--uuid', type=str, default="", help="Filter events on object_uuid")
+ parser.add_argument('-f', '--filters', type=str, default="", help="Arvados query filter to apply to log events (JSON encoded)")
+
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument('--poll-interval', default=15, type=int, help="If websockets is not available, specify the polling interval, default is every 15 seconds")
+ group.add_argument('--no-poll', action='store_false', dest='poll_interval', help="Do not poll if websockets are not available, just fail")
+
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument('-p', '--pipeline', type=str, default="", help="Supply pipeline uuid, print log output from pipeline and its jobs")
+ group.add_argument('-j', '--job', type=str, default="", help="Supply job uuid, print log output from jobs")
+
+ args = parser.parse_args(arguments)
+
+ global filters
+ global known_component_jobs
+ global ws
+
+ filters = []
+ known_component_jobs = set()
+ ws = None
+
+ def update_subscribed_components(components):
+ global known_component_jobs
+ global filters
+ pipeline_jobs = set()
+ for c in components:
+ if "job" in components[c]:
+ pipeline_jobs.add(components[c]["job"]["uuid"])
+ if known_component_jobs != pipeline_jobs:
+ ws.unsubscribe(filters)
+ filters = [['object_uuid', 'in', [args.pipeline] + list(pipeline_jobs)]]
+ ws.subscribe([['object_uuid', 'in', [args.pipeline] + list(pipeline_jobs)]])
+ known_component_jobs = pipeline_jobs
+
+ api = arvados.api('v1', cache=False)
+
+ if args.uuid:
+ filters += [ ['object_uuid', '=', args.uuid] ]
+
+ if args.filters:
+ filters += json.loads(args.filters)
+
+ if args.job:
+ filters += [ ['object_uuid', '=', args.job] ]
+
+ if args.pipeline:
+ filters += [ ['object_uuid', '=', args.pipeline] ]
+
+ def on_message(ev):
+ global filters
+ global ws
+
+ logger.debug(ev)
+ if 'event_type' in ev and (args.pipeline or args.job):
+ if ev['event_type'] in ('stderr', 'stdout'):
+ sys.stdout.write(ev["properties"]["text"])
+ elif ev["event_type"] in ("create", "update"):
+ if ev["object_kind"] == "arvados#pipelineInstance":
+ update_subscribed_components(ev["properties"]["new_attributes"]["components"])
+ elif 'status' in ev and ev['status'] == 200:
+ pass
+ else:
+ print json.dumps(ev)
+
+ try:
+ ws = subscribe(arvados.api('v1', cache=False), filters, on_message, poll_fallback=args.poll_interval)
+ if ws:
+ if args.pipeline:
+ c = api.pipeline_instances().get(uuid=args.pipeline).execute()
+ update_subscribed_components(c["components"])
+
+ while True:
+ signal.pause()
+ except KeyboardInterrupt:
+ pass
+ except Exception as e:
+ logger.error(e)
+ finally:
+ if ws:
+ ws.close()
from ws4py.client.threadedclient import WebSocketClient
-import thread
+import threading
import json
import os
import time
import re
import config
import logging
+import arvados
_logger = logging.getLogger('arvados.events')
ssl_options={'cert_reqs': ssl.CERT_NONE}
else:
ssl_options={'cert_reqs': ssl.CERT_REQUIRED}
-
- super(EventClient, self).__init__(url, ssl_options)
+ super(EventClient, self).__init__(url, ssl_options=ssl_options)
self.filters = filters
self.on_event = on_event
def opened(self):
- self.send(json.dumps({"method": "subscribe", "filters": self.filters}))
+ self.subscribe(self.filters)
def received_message(self, m):
self.on_event(json.loads(str(m)))
except:
pass
-def subscribe(api, filters, on_event):
+ def subscribe(self, filters, last_log_id=None):
+ m = {"method": "subscribe", "filters": filters}
+ if last_log_id is not None:
+ m["last_log_id"] = last_log_id
+ self.send(json.dumps(m))
+
+ def unsubscribe(self, filters):
+ self.send(json.dumps({"method": "unsubscribe", "filters": filters}))
+
+class PollClient(threading.Thread):
+ def __init__(self, api, filters, on_event, poll_time):
+ super(PollClient, self).__init__()
+ self.api = api
+ if filters:
+ self.filters = [filters]
+ else:
+ self.filters = [[]]
+ self.on_event = on_event
+ self.poll_time = poll_time
+ self.stop = threading.Event()
+
+ def run(self):
+ self.id = 0
+ for f in self.filters:
+ items = self.api.logs().list(limit=1, order="id desc", filters=f).execute()['items']
+ if items:
+ if items[0]['id'] > self.id:
+ self.id = items[0]['id']
+
+ self.on_event({'status': 200})
+
+ while not self.stop.isSet():
+ max_id = self.id
+ for f in self.filters:
+ items = self.api.logs().list(order="id asc", filters=f+[["id", ">", str(self.id)]]).execute()['items']
+ for i in items:
+ if i['id'] > max_id:
+ max_id = i['id']
+ self.on_event(i)
+ self.id = max_id
+ self.stop.wait(self.poll_time)
+
+ def close(self):
+ self.stop.set()
+ self.join()
+
+ def subscribe(self, filters):
+ self.on_event({'status': 200})
+ self.filters.append(filters)
+
+ def unsubscribe(self, filters):
+ del self.filters[self.filters.index(filters)]
+
+
+def subscribe(api, filters, on_event, poll_fallback=15):
+ '''
+ api: Must be a newly created from arvados.api(cache=False), not shared with the caller, as it may be used by a background thread.
+ filters: Initial subscription filters.
+ on_event: The callback when a message is received
+ poll_fallback: If websockets are not available, fall back to polling every N seconds. If poll_fallback=False, this will return None if websockets are not available.
+ '''
ws = None
- try:
- url = "{}?api_token={}".format(api._rootDesc['websocketUrl'], config.get('ARVADOS_API_TOKEN'))
- ws = EventClient(url, filters, on_event)
- ws.connect()
- return ws
- except Exception:
- if (ws):
- ws.close_connection()
- raise
+ if 'websocketUrl' in api._rootDesc:
+ try:
+ url = "{}?api_token={}".format(api._rootDesc['websocketUrl'], api.api_token)
+ ws = EventClient(url, filters, on_event)
+ ws.connect()
+ return ws
+ except Exception as e:
+ _logger.warn("Got exception %s trying to connect to websockets at %s" % (e, api._rootDesc['websocketUrl']))
+ if ws:
+ ws.close_connection()
+ if poll_fallback:
+ _logger.warn("Websockets not available, falling back to log table polling")
+ p = PollClient(api, filters, on_event, poll_fallback)
+ p.start()
+ return p
+ else:
+ _logger.error("Websockets not available")
+ return None
#!/usr/bin/env python
-import sys
-import logging
-import argparse
-import arvados
-from arvados.events import subscribe
-
-logger = logging.getLogger('arvados.arv-ws')
-
-parser = argparse.ArgumentParser()
-parser.add_argument('-u', '--uuid', type=str, default="")
-args = parser.parse_args()
-
-filters = []
-if len(args.uuid)>0: filters = [ ['object_uuid', '=', args.uuid] ]
-
-api = arvados.api('v1', cache=False)
-
-def on_message(ev):
- print "\n", ev
-
-ws = None
-try:
- ws = subscribe(api, filters, lambda ev: on_message(ev))
- ws.run_forever()
-except Exception:
- logger.exception('')
- if (ws):
- ws.close_connection()
+from arvados.commands.ws import main
+main()
import unittest
import arvados
import arvados.events
-import time
-
-class WebsocketTest(run_test_server.TestCaseWithServers):
- MAIN_SERVER = {'websockets': True}
+import threading
+class EventTestBase(object):
def on_event(self, ev):
if self.state == 1:
self.assertEqual(200, ev['status'])
self.state = 2
+ self.subscribed.set()
elif self.state == 2:
self.assertEqual(self.h[u'uuid'], ev[u'object_uuid'])
self.state = 3
+ self.done.set()
elif self.state == 3:
self.fail()
def runTest(self):
+ self.ws = None
self.state = 1
+ self.subscribed = threading.Event()
+ self.done = threading.Event()
run_test_server.authorize_with("admin")
api = arvados.api('v1', cache=False)
- arvados.events.subscribe(api, [['object_uuid', 'is_a', 'arvados#human']], lambda ev: self.on_event(ev))
- time.sleep(1)
+ self.ws = arvados.events.subscribe(arvados.api('v1', cache=False), [['object_uuid', 'is_a', 'arvados#human']], self.on_event, poll_fallback=2)
+ self.assertIsInstance(self.ws, self.WS_TYPE)
+ self.subscribed.wait(10)
self.h = api.humans().create(body={}).execute()
- time.sleep(1)
+ self.done.wait(10)
+ self.assertEqual(3, self.state)
+
+class WebsocketTest(run_test_server.TestCaseWithServers, EventTestBase):
+ MAIN_SERVER = {'websockets': True}
+ WS_TYPE = arvados.events.EventClient
+
+ def tearDown(self):
+ if self.ws:
+ self.ws.close()
+ super(WebsocketTest, self).tearDown()
+
+
+class PollClientTest(run_test_server.TestCaseWithServers, EventTestBase):
+ MAIN_SERVER = {}
+ WS_TYPE = arvados.events.PollClient
+
+ def tearDown(self):
+ if self.ws:
+ self.ws.close()
+ super(PollClientTest, self).tearDown()
unless (owner_uuid == current_user.uuid or
current_user.is_admin or
(current_user.groups_i_can(:manage) & [uuid, owner_uuid]).any?)
- if current_user.groups_i_can(:write).index(uuid)
+ if ((current_user.groups_i_can(:write) + [current_user.uuid]) &
+ [uuid, owner_uuid]).any?
return [owner_uuid, current_user.uuid]
else
return [owner_uuid]
t.add :is_admin
t.add :is_invited
t.add :prefs
+ t.add :writable_by
end
ALL_PERMISSIONS = {read: true, write: true, manage: true}
# Add a filter. This gets the :filters field which is the same
# format as used for regular index queries.
ws.filters << Filter.new(p)
- ws.send ({status: 200, message: 'subscribe ok'}.to_json)
+ ws.send ({status: 200, message: 'subscribe ok', filter: p}.to_json)
# Send any pending events
push_events ws
api_token: projectviewertoken1234567890abcdefghijklmnopqrstuv
expires_at: 2038-01-01 00:00:00
+subproject_admin:
+ api_client: untrusted
+ user: subproject_admin
+ api_token: subprojectadmintoken1234567890abcdefghijklmnopqrst
+ expires_at: 2038-01-01 00:00:00
+
admin_vm:
api_client: untrusted
user: admin
created_at: 2014-02-03T17:22:54Z
name: collection_owned_by_foo
+collection_to_remove_from_subproject:
+ # The Workbench tests remove this from subproject.
+ uuid: zzzzz-4zz18-subprojgonecoll
+ portable_data_hash: 2386ca6e3fffd4be5e197a72c6c80fb2+51
+ manifest_text: ". 8258b505536a9ab47baa2f4281cb932a+9 0:9:missingno\n"
+ owner_uuid: zzzzz-j7d0g-axqo7eu9pwvna1x
+ created_at: 2014-10-15T10:45:00
+ name: Collection to remove from subproject
+
# Test Helper trims the rest of the file
# Do not add your fixtures below this line as the rest of this file will be trimmed by test_helper
runtime_constraints: {}
state: Cancelled
+job_in_subproject:
+ uuid: zzzzz-8i9sb-subprojectjob01
+ created_at: 2014-10-15 12:00:00
+ owner_uuid: zzzzz-j7d0g-axqo7eu9pwvna1x
+ log: ~
+ repository: foo
+ script: hash
+ script_version: 4fe459abe02d9b365932b8f5dc419439ab4e2577
+ state: Complete
+
# Test Helper trims the rest of the file
# Do not add your fixtures below this line as the rest of this file will be trimmed by test_helper
head_uuid: zzzzz-j7d0g-v955i6s2oi1cbso
properties: {}
+subproject_admin_can_manage_subproject:
+ uuid: zzzzz-o0j2j-subprojadminlnk
+ owner_uuid: zzzzz-tpzed-000000000000000
+ created_at: 2014-10-15 10:00:00 -0000
+ modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
+ modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+ modified_at: 2014-10-15 10:00:00 -0000
+ updated_at: 2014-10-15 10:00:00 -0000
+ tail_uuid: zzzzz-tpzed-subprojectadmin
+ link_class: permission
+ name: can_manage
+ head_uuid: zzzzz-j7d0g-axqo7eu9pwvna1x
+ properties: {}
+
foo_collection_tag:
uuid: zzzzz-o0j2j-eedahfaho8aphiv
owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
created_at: <%= 1.minute.ago.to_s(:db) %>
+new_pipeline_in_subproject:
+ state: New
+ uuid: zzzzz-d1hrv-subprojpipeline
+ owner_uuid: zzzzz-j7d0g-axqo7eu9pwvna1x
+ created_at: <%= 1.minute.ago.to_s(:db) %>
+
has_component_with_no_script_parameters:
state: Ready
uuid: zzzzz-d1hrv-1xfj6xkicf2muk2
organization: example.com
role: Computational biologist
+subproject_admin:
+ owner_uuid: zzzzz-tpzed-000000000000000
+ uuid: zzzzz-tpzed-subprojectadmin
+ email: subproject-admin@arvados.local
+ first_name: Subproject
+ last_name: Admin
+ identity_url: https://subproject-admin.openid.local
+ is_active: true
+ is_admin: false
+ prefs:
+ profile:
+ organization: example.com
+ role: Computational biologist
+
spectator:
owner_uuid: zzzzz-tpzed-000000000000000
uuid: zzzzz-tpzed-l1s2piq4t4mps8r
assert_equal false, found_email, 'Expected no email after updating profile'
end
+ test "user API response includes writable_by" do
+ authorize_with :active
+ get :current
+ assert_response :success
+ assert_includes(json_response["writable_by"], users(:active).uuid,
+ "user's writable_by should include self")
+ assert_includes(json_response["writable_by"], users(:active).owner_uuid,
+ "user's writable_by should include its owner_uuid")
+ end
+
NON_ADMIN_USER_DATA = ["uuid", "kind", "is_active", "email", "first_name",
"last_name"].sort