diff --git a/assets/javascripts/discourse/components/group-reports-nav-item.js.es6 b/assets/javascripts/discourse/components/group-reports-nav-item.js.es6 new file mode 100644 index 0000000..6280436 --- /dev/null +++ b/assets/javascripts/discourse/components/group-reports-nav-item.js.es6 @@ -0,0 +1,21 @@ +import { ajax } from "discourse/lib/ajax"; + +export default Ember.Component.extend({ + group: null, + showReportsTab: false, + + checkForReports() { + return ajax(`/g/${this.group.name}/reports`).then(response => { + return this.set("showReportsTab", response.queries.length > 0); + }); + }, + + init(args) { + this.set("group", args.group); + if (this.currentUser.groups.some(g => g.id === this.group.id)) { + // User is a part of the group. Now check if the group has reports + this.checkForReports(); + } + this._super(args); + } +}); diff --git a/assets/javascripts/discourse/components/query-result.js.es6 b/assets/javascripts/discourse/components/query-result.js.es6 index 87d4ff8..d359c00 100644 --- a/assets/javascripts/discourse/components/query-result.js.es6 +++ b/assets/javascripts/discourse/components/query-result.js.es6 @@ -146,6 +146,12 @@ const QueryResultComponent = Ember.Component.extend({ return this.site.get("categoriesById")[id]; }, + download_url() { + return this.group + ? `/g/${this.group.name}/reports/` + : "/admin/plugins/explorer/queries/"; + }, + downloadResult(format) { // Create a frame to submit the form in (?) // to avoid leaving an about:blank behind @@ -161,7 +167,7 @@ const QueryResultComponent = Ember.Component.extend({ form.setAttribute( "action", Discourse.getURL( - "/admin/plugins/explorer/queries/" + + this.download_url() + this.get("query.id") + "/run." + format + diff --git a/assets/javascripts/discourse/controllers/admin-plugins-explorer.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-explorer.js.es6 index d6bc55f..73cb79b 100644 --- a/assets/javascripts/discourse/controllers/admin-plugins-explorer.js.es6 +++ b/assets/javascripts/discourse/controllers/admin-plugins-explorer.js.es6 @@ -54,6 +54,22 @@ export default Ember.Controller.extend({ return item || NoQuery; }, + @computed("selectedItem", "editing") + selectedGroupNames(selectedItem) { + const groupIds = this.selectedItem.group_ids || []; + const groupNames = groupIds.map(id => { + return this.groupOptions.find(groupOption => groupOption.id == id).name; + }); + return groupNames.join(", "); + }, + + @computed("groups") + groupOptions(groups) { + return groups.arrangedContent.map(g => { + return { id: g.id.toString(), name: g.name }; + }); + }, + @computed("selectedItem", "selectedItem.dirty") othersDirty(selectedItem) { return !!this.model.find(q => q !== selectedItem && q.dirty); @@ -81,6 +97,7 @@ export default Ember.Controller.extend({ this.set("loading", true); if (this.get("selectedItem.description") === "") this.set("selectedItem.description", ""); + return this.selectedItem .save() .then(() => { @@ -183,6 +200,8 @@ export default Ember.Controller.extend({ .then(result => { const query = this.get("selectedItem"); query.setProperties(result.getProperties(Query.updatePropertyNames)); + if (!query.group_ids || !Array.isArray(query.group_ids)) + query.set("group_ids", []); query.markNotDirty(); this.set("editing", false); }) diff --git a/assets/javascripts/discourse/controllers/group-reports-index.js.es6 b/assets/javascripts/discourse/controllers/group-reports-index.js.es6 new file mode 100644 index 0000000..b2573e3 --- /dev/null +++ b/assets/javascripts/discourse/controllers/group-reports-index.js.es6 @@ -0,0 +1,10 @@ +import Query from "discourse/plugins/discourse-data-explorer/discourse/models/query"; +import { ajax } from "discourse/lib/ajax"; +import { + default as computed, + observes +} from "ember-addons/ember-computed-decorators"; + +export default Ember.Controller.extend({ + queries: Ember.computed.alias("model.queries") +}); diff --git a/assets/javascripts/discourse/controllers/group-reports-show.js.es6 b/assets/javascripts/discourse/controllers/group-reports-show.js.es6 new file mode 100644 index 0000000..02f7030 --- /dev/null +++ b/assets/javascripts/discourse/controllers/group-reports-show.js.es6 @@ -0,0 +1,44 @@ +import Query from "discourse/plugins/discourse-data-explorer/discourse/models/query"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { ajax } from "discourse/lib/ajax"; +import { + default as computed, + observes +} from "ember-addons/ember-computed-decorators"; + +export default Ember.Controller.extend({ + showResults: false, + explain: false, + loading: false, + results: Ember.computed.alias("model.results"), + hasParams: Ember.computed.gt("model.param_info.length", 0), + + actions: { + run() { + this.setProperties({ loading: true, showResults: false }); + ajax(`/g/${this.get("group.name")}/reports/${this.model.id}/run`, { + type: "POST", + data: { + params: JSON.stringify(this.model.params), + explain: this.explain + } + }) + .then(result => { + this.set("results", result); + if (!result.success) { + return; + } + + this.set("showResults", true); + }) + .catch(err => { + if (err.jqXHR && err.jqXHR.status === 422 && err.jqXHR.responseJSON) { + this.set("results", err.jqXHR.responseJSON); + } else { + popupAjaxError(err); + } + }) + .finally(() => this.set("loading", false)); + } + } +}); diff --git a/assets/javascripts/discourse/group-reports-route-map.js.es6 b/assets/javascripts/discourse/group-reports-route-map.js.es6 new file mode 100644 index 0000000..f354996 --- /dev/null +++ b/assets/javascripts/discourse/group-reports-route-map.js.es6 @@ -0,0 +1,9 @@ +export default { + resource: "group", + + map() { + this.route("reports", function() { + this.route("show", { path: "/:query_id" }); + }); + } +}; diff --git a/assets/javascripts/discourse/models/query.js.es6 b/assets/javascripts/discourse/models/query.js.es6 index 3575630..501db9a 100644 --- a/assets/javascripts/discourse/models/query.js.es6 +++ b/assets/javascripts/discourse/models/query.js.es6 @@ -23,7 +23,7 @@ const Query = RestModel.extend({ this.resetParams(); }, - @observes("name", "description", "sql") + @observes("name", "description", "sql", "group_ids") markDirty() { this.set("dirty", true); }, @@ -85,6 +85,7 @@ Query.reopenClass({ "sql", "created_by", "created_at", + "group_ids", "last_run_at" ] }); diff --git a/assets/javascripts/discourse/routes/admin-plugins-explorer.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-explorer.js.es6 index 6b21191..f6db09c 100644 --- a/assets/javascripts/discourse/routes/admin-plugins-explorer.js.es6 +++ b/assets/javascripts/discourse/routes/admin-plugins-explorer.js.es6 @@ -4,19 +4,36 @@ export default Discourse.Route.extend({ controllerName: "admin-plugins-explorer", model() { - const p1 = this.store.findAll("query"); - const p2 = ajax("/admin/plugins/explorer/schema.json", { cache: true }); - return p1 - .then(model => { - model.forEach(query => query.markNotDirty()); + const groupPromise = this.store.findAll("group"); + const schemaPromise = ajax("/admin/plugins/explorer/schema.json", { cache: true }); + const queryPromise = this.store.findAll("query"); - return p2.then(schema => { - return { model, schema }; + return groupPromise + .then(groups => { + let groupNames = {}; + groups.forEach(g => { + groupNames[g.id] = g.name; + }); + return schemaPromise.then(schema => { + return queryPromise.then(model => { + model.forEach(query => { + query.markNotDirty(); + query.set( + "group_names", + query.group_ids + .map(id => groupNames[id]) + .filter(n => n) + .join(", ") + ); + }); + return { model, schema, groups }; + }); }); }) .catch(() => { - p2.catch(() => {}); - return { model: null, schema: null, disallow: true }; + schemaPromise.catch(() => {}); + queryPromise.catch(() => {}); + return { model: null, schema: null, disallow: true, groups: null }; }); }, diff --git a/assets/javascripts/discourse/routes/group-reports-index.js.es6 b/assets/javascripts/discourse/routes/group-reports-index.js.es6 new file mode 100644 index 0000000..2bf6a40 --- /dev/null +++ b/assets/javascripts/discourse/routes/group-reports-index.js.es6 @@ -0,0 +1,38 @@ +import { ajax } from "discourse/lib/ajax"; + +export default Discourse.Route.extend({ + controllerName: "group-reports-index", + + model() { + const group = this.modelFor("group"); + return ajax(`/g/${group.name}/reports`) + .then(queries => { + return { + model: queries, + group: group + }; + }) + .catch(() => { + this.transitionTo("group.members", group); + }); + }, + afterModel(model) { + if ( + !model.group.get("is_group_user") && + !(this.currentUser && this.currentUser.admin) + ) { + this.transitionTo("group.members", group); + } + }, + + setupController(controller, model) { + controller.setProperties(model); + }, + + actions: { + refreshModel() { + this.refresh(); + return false; + } + } +}); diff --git a/assets/javascripts/discourse/routes/group-reports-show.js.es6 b/assets/javascripts/discourse/routes/group-reports-show.js.es6 new file mode 100644 index 0000000..caf8e2b --- /dev/null +++ b/assets/javascripts/discourse/routes/group-reports-show.js.es6 @@ -0,0 +1,30 @@ +import { ajax } from "discourse/lib/ajax"; + +export default Discourse.Route.extend({ + controllerName: "group-reports-show", + + model(params) { + const group = this.modelFor("group"); + return ajax(`/g/${group.name}/reports/${params.query_id}`) + .then(response => { + return { + model: Object.assign({ params: {} }, response.query), + group: group + }; + }) + .catch(err => { + this.transitionTo("group.members", group); + }); + }, + + setupController(controller, model) { + controller.setProperties(model); + }, + + actions: { + refreshModel() { + this.refresh(); + return false; + } + } +}); diff --git a/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs b/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs index 240f317..9a00a54 100644 --- a/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs +++ b/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs @@ -38,6 +38,7 @@
{{textarea value=selectedItem.description placeholder=(i18n "explorer.description_placeholder")}}
+ {{else}}
{{d-button action=(action "goHome") icon="chevron-left" class="previous"}} @@ -52,6 +53,20 @@
{{/if}} +
+ Allow groups to acess this query + + {{multi-select values=selectedItem.group_ids content=groupOptions}} + + {{#if runDisabled}} + {{#unless editing}} + + {{d-button class="ok" action=(action "save") icon="check"}} + {{d-button class="cancel" action=(action "discard") icon="times"}} + + {{/unless}} + {{/if}} +
{{! the SQL editor will show the first time you }} {{#if everEditing}}
@@ -158,6 +173,11 @@ {{directory-toggle field="username" labelKey="explorer.query_user" order=order asc=asc}}
+ +
+ {{i18n "explorer.query_groups"}} +
+
{{directory-toggle field="last_run_at" labelKey="explorer.query_time" order=order asc=asc}} @@ -181,6 +201,11 @@ {{/if}} + + {{#if query.group_names}} + {{query.group_names}} + {{/if}} + {{#if query.last_run_at}} diff --git a/assets/javascripts/discourse/templates/components/group-reports-nav-item.hbs b/assets/javascripts/discourse/templates/components/group-reports-nav-item.hbs new file mode 100644 index 0000000..5fd40cd --- /dev/null +++ b/assets/javascripts/discourse/templates/components/group-reports-nav-item.hbs @@ -0,0 +1,9 @@ +{{#if showReportsTab}} + +{{/if}} \ No newline at end of file diff --git a/assets/javascripts/discourse/templates/connectors/group-reports-nav-item/nav-item.hbs b/assets/javascripts/discourse/templates/connectors/group-reports-nav-item/nav-item.hbs new file mode 100644 index 0000000..f29f5b2 --- /dev/null +++ b/assets/javascripts/discourse/templates/connectors/group-reports-nav-item/nav-item.hbs @@ -0,0 +1 @@ +{{group-reports-nav-item group = group}} \ No newline at end of file diff --git a/assets/javascripts/discourse/templates/explorer-query-result.hbs b/assets/javascripts/discourse/templates/explorer-query-result.hbs index c8771d4..68370aa 100644 --- a/assets/javascripts/discourse/templates/explorer-query-result.hbs +++ b/assets/javascripts/discourse/templates/explorer-query-result.hbs @@ -1,6 +1,6 @@
- {{d-button action=(action "downloadResultJson") icon="download" label="explorer.download_json"}} - {{d-button action=(action "downloadResultCsv") icon="download" label="explorer.download_csv"}} + {{d-button action=(action "downloadResultJson") icon="download" label="explorer.download_json" group=group}} + {{d-button action=(action "downloadResultCsv") icon="download" label="explorer.download_csv" group=group}}
diff --git a/assets/javascripts/discourse/templates/group-reports-index.hbs b/assets/javascripts/discourse/templates/group-reports-index.hbs new file mode 100644 index 0000000..17c55a5 --- /dev/null +++ b/assets/javascripts/discourse/templates/group-reports-index.hbs @@ -0,0 +1,27 @@ +
+ + + + + + + + + {{#each queries as |query|}} + + + + + + {{/each}} + +
+ {{i18n "explorer.report_name"}} + + {{i18n "explorer.query_description"}} + + {{i18n "explorer.query_time"}} +
+ {{#link-to 'group.reports.show' group.name query.id}}{{query.name}}{{/link-to}} + {{query.description}}{{bound-date query.last_run_at}}
+
diff --git a/assets/javascripts/discourse/templates/group-reports-show.hbs b/assets/javascripts/discourse/templates/group-reports-show.hbs new file mode 100644 index 0000000..2e6c9da --- /dev/null +++ b/assets/javascripts/discourse/templates/group-reports-show.hbs @@ -0,0 +1,26 @@ +
+

{{model.name}}

+

{{model.description}}

+
+ {{#if hasParams}} +
+ {{#each model.param_info as |pinfo|}} + {{param-input params=model.params info=pinfo}} + {{/each}} +
+ {{/if}} + {{d-button action=(action "run") icon="play" label="explorer.run" class="btn-primary" type="submit"}} +
+ {{conditional-loading-spinner condition=loading}} + {{#if results}} +
+ {{#if showResults}} + {{query-result query=model content=results group=group}} + {{else}} + {{#each results.errors as |err|}} +
{{~err}}
+ {{/each}} + {{/if}} +
+ {{/if}} +
diff --git a/assets/stylesheets/explorer.scss b/assets/stylesheets/explorer.scss index fee53db..ae4d999 100644 --- a/assets/stylesheets/explorer.scss +++ b/assets/stylesheets/explorer.scss @@ -1,3 +1,39 @@ +table.group-reports { + width: 100%; + table-layout: fixed; + + th:first-child { + width: 30%; + } + th:nth-child(2) { + width: 60%; + } + th:last-child { + width: 20%; + text-align: right; + } + tbody { + border-top: 3px solid #e3ebf2; + + tr { + border-bottom: 1px solid #e3ebf2; + + td { + color: #7499bd; + padding: 0.8em 0.5em; + } + + td:first-child { + font-size: 16px; + } + + td:last-child { + text-align: right; + } + } + } +} + .https-warning { color: $danger; } @@ -185,6 +221,16 @@ &:not(.editing) .desc { margin: 10px 0; } + .groups { + margin-bottom: 10px; + .label { + margin-right: 10px; + color: $primary-high; + } + .name { + display: inline; + } + } } .query-run { @@ -244,6 +290,16 @@ margin: 10px 0; } +.query-results { + table { + width: 100%; + margin-top: 10px; + td { + padding: 8px; + } + } +} + .query-list { display: flex; max-height: 30px; @@ -262,7 +318,15 @@ .recent-queries { thead { .created-by { - width: 20%; + width: 15%; + } + .group-names { + width: 15%; + .group-names-header { + position: absolute; + bottom: 8px; + left: 6px; + } } .created-at { width: 15%; @@ -295,6 +359,9 @@ .query-created-by { color: $primary-high; } + .query-group-names { + color: $primary-high; + } .query-created-at { color: $primary-medium; } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index c931185..ae4a7bf 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -59,6 +59,8 @@ en: one: "Showing top %{count} results." other: "Showing top %{count} results." query_name: "Query" + query_groups: "Groups" + report_name: "Report" query_description: "Description" query_time: "Last run" query_user: "Created by" @@ -68,3 +70,6 @@ en: reset_params: "Reset" search_placeholder: "Search..." no_search_results: "Sorry, we couldn't find any results matching your text." + group: + reports: "Reports" + diff --git a/plugin.rb b/plugin.rb index 661173e..2a3333c 100644 --- a/plugin.rb +++ b/plugin.rb @@ -44,6 +44,19 @@ end after_initialize do + add_to_class(:guardian, :user_is_a_member_of_group?) do |group| + return false if !current_user + return true if current_user.admin? + return current_user.group_ids.include?(group.id) + end + + add_to_class(:guardian, :user_can_access_query?) do |group, query| + return false if !current_user + return true if current_user.admin? + return user_is_a_member_of_group?(group) && + query.group_ids.include?(group.id.to_s) + end + module ::DataExplorer class Engine < ::Rails::Engine engine_name "data_explorer" @@ -595,12 +608,13 @@ SQL # Reimplement a couple ActiveRecord methods, but use PluginStore for storage instead require_dependency File.expand_path('../lib/queries.rb', __FILE__) class DataExplorer::Query - attr_accessor :id, :name, :description, :sql, :created_by, :created_at, :last_run_at + attr_accessor :id, :name, :description, :sql, :created_by, :created_at, :group_ids, :last_run_at def initialize @name = 'Unnamed Query' @description = '' @sql = 'SELECT 1' + @group_ids = [] end def slug @@ -624,6 +638,10 @@ SQL result end + def can_be_run_by(group) + @group_ids.include?(group.id.to_s) + end + # saving/loading functions # May want to extract this into a library or something for plugins to use? def self.alloc_id @@ -640,6 +658,7 @@ SQL [:name, :description, :sql, :created_by, :created_at, :last_run_at].each do |sym| query.send("#{sym}=", h[sym].strip) if h[sym] end + query.group_ids = h[:group_ids] query.id = h[:id].to_i if h[:id] query end @@ -652,6 +671,7 @@ SQL sql: @sql, created_by: @created_by, created_at: @created_at, + group_ids: @group_ids, last_run_at: @last_run_at } end @@ -672,7 +692,9 @@ SQL def save check_params! - @id = self.class.alloc_id unless @id && @id > 0 + return save_default_query if @id && @id < 0 + + @id = @id ||self.class.alloc_id DataExplorer.pstore_set "q:#{id}", to_hash end @@ -682,6 +704,7 @@ SQL query = Queries.default[id.to_s] @id = query["id"] @sql = query["sql"] + @group_ids = @group_ids || [] @name = query["name"] @description = query["description"] @@ -958,11 +981,23 @@ SQL requires_plugin DataExplorer.plugin_name before_action :check_enabled + before_action :set_group, only: [:group_reports_index, :group_reports_show, :group_reports_run] + before_action :set_query, only: [:group_reports_show, :group_reports_run] + + attr_reader :group, :query def check_enabled raise Discourse::NotFound unless SiteSetting.data_explorer_enabled? end + def set_group + @group = Group.find_by(name: params["group_name"]) + end + + def set_query + @query = DataExplorer::Query.find(params[:id].to_i) + end + def index # guardian.ensure_can_use_data_explorer! queries = DataExplorer::Query.all @@ -996,6 +1031,36 @@ SQL render_serialized query, DataExplorer::QuerySerializer, root: 'query' end + def group_reports_index + return raise Discourse::NotFound unless guardian.user_is_a_member_of_group?(group) + + respond_to do |format| + format.html { render 'groups/show' } + format.json do + queries = DataExplorer::Query.all + queries.select! { |query| query.group_ids.include?(group.id.to_s) } + render_serialized queries, DataExplorer::QuerySerializer, root: 'queries' + end + end + end + + def group_reports_show + return raise Discourse::NotFound unless guardian.user_can_access_query?(group, query) + + respond_to do |format| + format.html { render 'groups/show' } + format.json do + render_serialized query, DataExplorer::QuerySerializer, root: 'query' + end + end + end + + def group_reports_run + return raise Discourse::NotFound unless guardian.user_can_access_query?(group, query) + + run + end + def create # guardian.ensure_can_create_explorer_query! @@ -1012,6 +1077,7 @@ SQL def update query = DataExplorer::Query.find(params[:id].to_i, ignore_deleted: true) hash = params.require(:query) + hash[:group_ids] ||= [] # Undeleting unless query.id @@ -1022,7 +1088,7 @@ SQL end end - [:name, :sql, :description, :created_by, :created_at, :last_run_at].each do |sym| + [:name, :sql, :description, :created_by, :created_at, :group_ids, :last_run_at].each do |sym| query.send("#{sym}=", hash[sym]) if hash[sym] end @@ -1154,10 +1220,10 @@ SQL end end end - end + end class DataExplorer::QuerySerializer < ActiveModel::Serializer - attributes :id, :sql, :name, :description, :param_info, :created_by, :created_at, :username, :last_run_at + attributes :id, :sql, :name, :description, :param_info, :created_by, :created_at, :username, :group_ids, :last_run_at def param_info object.params.map(&:to_hash) rescue nil @@ -1180,6 +1246,10 @@ SQL end Discourse::Application.routes.append do + get '/g/:group_name/reports' => 'data_explorer/query#group_reports_index' + get '/g/:group_name/reports/:id' => 'data_explorer/query#group_reports_show' + post '/g/:group_name/reports/:id/run' => 'data_explorer/query#group_reports_run' + mount ::DataExplorer::Engine, at: '/admin/plugins/explorer', constraints: AdminConstraint.new end end diff --git a/spec/controllers/queries_controller_spec.rb b/spec/controllers/queries_controller_spec.rb index 0f97beb..80e51f5 100644 --- a/spec/controllers/queries_controller_spec.rb +++ b/spec/controllers/queries_controller_spec.rb @@ -3,8 +3,6 @@ require 'rails_helper' describe DataExplorer::QueryController do - routes { ::DataExplorer::Engine.routes } - def response_json MultiJson.load(response.body) end @@ -13,238 +11,355 @@ describe DataExplorer::QueryController do SiteSetting.data_explorer_enabled = true end - let!(:admin) { log_in_user(Fabricate(:admin)) } - - def make_query(sql, opts = {}) + def make_query(sql, opts = {}, group_ids = []) q = DataExplorer::Query.new q.id = Fabrication::Sequencer.sequence("query-id", 1) q.name = opts[:name] || "Query number #{q.id}" q.description = "A description for query number #{q.id}" + q.group_ids = group_ids q.sql = sql q.save q end - describe "when disabled" do - before do - SiteSetting.data_explorer_enabled = false + describe "Admin" do + routes { ::DataExplorer::Engine.routes } + + let!(:admin) { log_in_user(Fabricate(:admin)) } + + describe "when disabled" do + before do + SiteSetting.data_explorer_enabled = false + end + it 'denies every request' do + get :index + expect(response.body).to be_empty + + get :index, format: :json + expect(response.status).to eq(404) + + get :schema, format: :json + expect(response.status).to eq(404) + + get :show, params: { id: 3 }, format: :json + expect(response.status).to eq(404) + + post :create, params: { id: 3 }, format: :json + expect(response.status).to eq(404) + + post :run, params: { id: 3 }, format: :json + expect(response.status).to eq(404) + + put :update, params: { id: 3 }, format: :json + expect(response.status).to eq(404) + + delete :destroy, params: { id: 3 }, format: :json + expect(response.status).to eq(404) + end end - it 'denies every request' do - get :index - expect(response.body).to be_empty - get :index, format: :json - expect(response.status).to eq(404) + describe "#index" do + before do + require_dependency File.expand_path('../../../lib/queries.rb', __FILE__) + end - get :schema, format: :json - expect(response.status).to eq(404) + it "behaves nicely with no user created queries" do + DataExplorer::Query.destroy_all + get :index, format: :json + expect(response.status).to eq(200) + expect(response_json['queries'].count).to eq(Queries.default.count) + end - get :show, params: { id: 3 }, format: :json - expect(response.status).to eq(404) + it "shows all available queries in alphabetical order" do + DataExplorer::Query.destroy_all + make_query('SELECT 1 as value', name: 'B') + make_query('SELECT 1 as value', name: 'A') + get :index, format: :json + expect(response.status).to eq(200) + expect(response_json['queries'].length).to eq(Queries.default.count + 2) + expect(response_json['queries'][0]['name']).to eq('A') + expect(response_json['queries'][1]['name']).to eq('B') + end + end - post :create, params: { id: 3 }, format: :json - expect(response.status).to eq(404) + describe "#run" do + let!(:admin) { log_in(:admin) } - post :run, params: { id: 3 }, format: :json - expect(response.status).to eq(404) + def run_query(id, params = {}) + params = Hash[params.map { |a| [a[0], a[1].to_s] }] + post :run, params: { id: id, _params: MultiJson.dump(params) }, format: :json + end + it "can run queries" do + q = make_query('SELECT 23 as my_value') + run_query q.id + expect(response.status).to eq(200) + expect(response_json['success']).to eq(true) + expect(response_json['errors']).to eq([]) + expect(response_json['columns']).to eq(['my_value']) + expect(response_json['rows']).to eq([[23]]) + end - put :update, params: { id: 3 }, format: :json - expect(response.status).to eq(404) + it "can process parameters" do + q = make_query <<~SQL + -- [params] + -- int :foo = 34 + SELECT :foo as my_value + SQL - delete :destroy, params: { id: 3 }, format: :json - expect(response.status).to eq(404) + run_query q.id, foo: 23 + expect(response.status).to eq(200) + expect(response_json['errors']).to eq([]) + expect(response_json['success']).to eq(true) + expect(response_json['columns']).to eq(['my_value']) + expect(response_json['rows']).to eq([[23]]) + + run_query q.id + expect(response.status).to eq(200) + expect(response_json['errors']).to eq([]) + expect(response_json['success']).to eq(true) + expect(response_json['columns']).to eq(['my_value']) + expect(response_json['rows']).to eq([[34]]) + + # 2.3 is not an integer + run_query q.id, foo: '2.3' + expect(response.status).to eq(422) + expect(response_json['errors']).to_not eq([]) + expect(response_json['success']).to eq(false) + expect(response_json['errors'].first).to match(/ValidationError/) + end + + it "doesn't allow you to modify the database #1" do + p = create_post + + q = make_query <<~SQL + UPDATE posts SET cooked = '

you may already be a winner!

' WHERE id = #{p.id} + RETURNING id + SQL + + run_query q.id + p.reload + + # Manual Test - comment out the following lines: + # DB.exec "SET TRANSACTION READ ONLY" + # raise ActiveRecord::Rollback + # This test should fail on the below check. + expect(p.cooked).to_not match(/winner/) + expect(response.status).to eq(422) + expect(response_json['errors']).to_not eq([]) + expect(response_json['success']).to eq(false) + expect(response_json['errors'].first).to match(/read-only transaction/) + end + + it "doesn't allow you to modify the database #2" do + p = create_post + + q = make_query <<~SQL + SELECT 1 + ) + SELECT * FROM query; + RELEASE SAVEPOINT active_record_1; + SET TRANSACTION READ WRITE; + UPDATE posts SET cooked = '

you may already be a winner!

' WHERE id = #{p.id}; + SAVEPOINT active_record_1; + SET TRANSACTION READ ONLY; + WITH query AS ( + SELECT 1 + SQL + + run_query q.id + p.reload + + # Manual Test - change out the following line: + # + # module ::DataExplorer + # def self.run_query(...) + # if query.sql =~ /;/ + # + # to + # + # if false && query.sql =~ /;/ + # + # Afterwards, this test should fail on the below check. + expect(p.cooked).to_not match(/winner/) + expect(response.status).to eq(422) + expect(response_json['errors']).to_not eq([]) + expect(response_json['success']).to eq(false) + expect(response_json['errors'].first).to match(/semicolon/) + end + + it "doesn't allow you to lock rows" do + q = make_query <<~SQL + SELECT id FROM posts FOR UPDATE + SQL + + run_query q.id + expect(response.status).to eq(422) + expect(response_json['errors']).to_not eq([]) + expect(response_json['success']).to eq(false) + expect(response_json['errors'].first).to match(/read-only transaction/) + end + + it "doesn't allow you to create a table" do + q = make_query <<~SQL + CREATE TABLE mytable (id serial) + SQL + + run_query q.id + expect(response.status).to eq(422) + expect(response_json['errors']).to_not eq([]) + expect(response_json['success']).to eq(false) + expect(response_json['errors'].first).to match(/read-only transaction|syntax error/) + end + + it "doesn't allow you to break the transaction" do + q = make_query <<~SQL + COMMIT + SQL + + run_query q.id + expect(response.status).to eq(422) + expect(response_json['errors']).to_not eq([]) + expect(response_json['success']).to eq(false) + expect(response_json['errors'].first).to match(/syntax error/) + + q.sql = <<~SQL + ) + SQL + + run_query q.id + expect(response.status).to eq(422) + expect(response_json['errors']).to_not eq([]) + expect(response_json['success']).to eq(false) + expect(response_json['errors'].first).to match(/syntax error/) + + q.sql = <<~SQL + RELEASE SAVEPOINT active_record_1 + SQL + + run_query q.id + expect(response.status).to eq(422) + expect(response_json['errors']).to_not eq([]) + expect(response_json['success']).to eq(false) + expect(response_json['errors'].first).to match(/syntax error/) + end + + it "can export data in CSV format" do + q = make_query('SELECT 23 as my_value') + post :run, params: { id: q.id, download: 1 }, format: :csv + expect(response.status).to eq(200) + end end end - describe "#index" do + describe "Non-Admin" do + routes { Discourse::Application.routes } + + let(:user) { Fabricate(:user) } + let(:group) { Fabricate(:group, users: [user]) } + before do - require_dependency File.expand_path('../../../lib/queries.rb', __FILE__) + log_in_user(user) end - it "behaves nicely with no user created queries" do - DataExplorer::Query.destroy_all - get :index, format: :json - expect(response.status).to eq(200) - expect(response_json['queries'].count).to eq(Queries.default.count) + describe "when disabled" do + before do + SiteSetting.data_explorer_enabled = false + end + + it 'denies every request' do + get :group_reports_index, params: { group_name: 1 }, format: :json + expect(response.status).to eq(404) + + get :group_reports_show, params: { group_name: 1, id: 1 }, format: :json + expect(response.status).to eq(404) + + post :group_reports_run, params: { group_name: 1, id: 1 }, format: :json + expect(response.status).to eq(404) + end end - it "shows all available queries in alphabetical order" do - DataExplorer::Query.destroy_all - make_query('SELECT 1 as value', name: 'B') - make_query('SELECT 1 as value', name: 'A') - get :index, format: :json - expect(response.status).to eq(200) - expect(response_json['queries'].length).to eq(Queries.default.count + 2) - expect(response_json['queries'][0]['name']).to eq('A') - expect(response_json['queries'][1]['name']).to eq('B') - end - end + describe "#group_reports_index" do - describe "#run" do - let!(:admin) { log_in(:admin) } + it "only returns queries that the group has access to" do + group.add(user) + make_query('SELECT 1 as value', {name: 'A'}, ["#{group.id}"]) - def run_query(id, params = {}) - params = Hash[params.map { |a| [a[0], a[1].to_s] }] - post :run, params: { id: id, _params: MultiJson.dump(params) }, format: :json - end - it "can run queries" do - q = make_query('SELECT 23 as my_value') - run_query q.id - expect(response.status).to eq(200) - expect(response_json['success']).to eq(true) - expect(response_json['errors']).to eq([]) - expect(response_json['columns']).to eq(['my_value']) - expect(response_json['rows']).to eq([[23]]) + get :group_reports_index, params: { group_name: group.name }, format: :json + expect(response.status).to eq(200) + expect(response_json['queries'].length).to eq(1) + expect(response_json['queries'][0]['name']).to eq('A') + end + + it "returns a 404 when the user should not have access to the query " do + user = Fabricate(:user) + log_in_user(user) + + get :group_reports_index, params: { group_name: group.name }, format: :json + expect(response.status).to eq(404) + end + + it "return a 200 when the user has access the the query" do + user = Fabricate(:user) + log_in_user(user) + group.add(user) + + get :group_reports_index, params: { group_name: group.name }, format: :json + expect(response.status).to eq(200) + end end - it "can process parameters" do - q = make_query <<~SQL - -- [params] - -- int :foo = 34 - SELECT :foo as my_value - SQL + describe "#group_reports_run" do + it "calls run on QueryController" do + query = make_query('SELECT 1 as value', {name: 'B'}, ["#{group.id}"]) + controller.expects(:run).at_least_once - run_query q.id, foo: 23 - expect(response.status).to eq(200) - expect(response_json['errors']).to eq([]) - expect(response_json['success']).to eq(true) - expect(response_json['columns']).to eq(['my_value']) - expect(response_json['rows']).to eq([[23]]) + get :group_reports_run, params: { group_name: group.name, id: query.id }, format: :json + end - run_query q.id - expect(response.status).to eq(200) - expect(response_json['errors']).to eq([]) - expect(response_json['success']).to eq(true) - expect(response_json['columns']).to eq(['my_value']) - expect(response_json['rows']).to eq([[34]]) + it "returns a 404 when the user should not have access to the query " do + user = Fabricate(:user) + log_in_user(user) + group.add(user) + query = make_query('SELECT 1 as value', {}, []) - # 2.3 is not an integer - run_query q.id, foo: '2.3' - expect(response.status).to eq(422) - expect(response_json['errors']).to_not eq([]) - expect(response_json['success']).to eq(false) - expect(response_json['errors'].first).to match(/ValidationError/) + get :group_reports_run, params: { group_name: group.name, id: query.id }, format: :json + expect(response.status).to eq(404) + end + + it "return a 200 when the user has access the the query" do + user = Fabricate(:user) + log_in_user(user) + group.add(user) + query = make_query('SELECT 1 as value', {}, [group.id.to_s]) + + get :group_reports_run, params: { group_name: group.name, id: query.id }, format: :json + expect(response.status).to eq(200) + end end - it "doesn't allow you to modify the database #1" do - p = create_post + describe "#group_reports_show" do + let(:group) { Fabricate(:group) } - q = make_query <<~SQL - UPDATE posts SET cooked = '

you may already be a winner!

' WHERE id = #{p.id} - RETURNING id - SQL + it "returns a 404 when the user should not have access to the query " do + user = Fabricate(:user) + log_in_user(user) + group.add(user) + query = make_query('SELECT 1 as value', {}, []) - run_query q.id - p.reload + get :group_reports_show, params: { group_name: group.name, id: query.id }, format: :json + expect(response.status).to eq(404) + end - # Manual Test - comment out the following lines: - # DB.exec "SET TRANSACTION READ ONLY" - # raise ActiveRecord::Rollback - # This test should fail on the below check. - expect(p.cooked).to_not match(/winner/) - expect(response.status).to eq(422) - expect(response_json['errors']).to_not eq([]) - expect(response_json['success']).to eq(false) - expect(response_json['errors'].first).to match(/read-only transaction/) - end + it "return a 200 when the user has access the the query" do + user = Fabricate(:user) + log_in_user(user) + group.add(user) + query = make_query('SELECT 1 as value', {}, [group.id.to_s]) - it "doesn't allow you to modify the database #2" do - p = create_post - - q = make_query <<~SQL - SELECT 1 - ) - SELECT * FROM query; - RELEASE SAVEPOINT active_record_1; - SET TRANSACTION READ WRITE; - UPDATE posts SET cooked = '

you may already be a winner!

' WHERE id = #{p.id}; - SAVEPOINT active_record_1; - SET TRANSACTION READ ONLY; - WITH query AS ( - SELECT 1 - SQL - - run_query q.id - p.reload - - # Manual Test - change out the following line: - # - # module ::DataExplorer - # def self.run_query(...) - # if query.sql =~ /;/ - # - # to - # - # if false && query.sql =~ /;/ - # - # Afterwards, this test should fail on the below check. - expect(p.cooked).to_not match(/winner/) - expect(response.status).to eq(422) - expect(response_json['errors']).to_not eq([]) - expect(response_json['success']).to eq(false) - expect(response_json['errors'].first).to match(/semicolon/) - end - - it "doesn't allow you to lock rows" do - q = make_query <<~SQL - SELECT id FROM posts FOR UPDATE - SQL - - run_query q.id - expect(response.status).to eq(422) - expect(response_json['errors']).to_not eq([]) - expect(response_json['success']).to eq(false) - expect(response_json['errors'].first).to match(/read-only transaction/) - end - - it "doesn't allow you to create a table" do - q = make_query <<~SQL - CREATE TABLE mytable (id serial) - SQL - - run_query q.id - expect(response.status).to eq(422) - expect(response_json['errors']).to_not eq([]) - expect(response_json['success']).to eq(false) - expect(response_json['errors'].first).to match(/read-only transaction|syntax error/) - end - - it "doesn't allow you to break the transaction" do - q = make_query <<~SQL - COMMIT - SQL - - run_query q.id - expect(response.status).to eq(422) - expect(response_json['errors']).to_not eq([]) - expect(response_json['success']).to eq(false) - expect(response_json['errors'].first).to match(/syntax error/) - - q.sql = <<~SQL - ) - SQL - - run_query q.id - expect(response.status).to eq(422) - expect(response_json['errors']).to_not eq([]) - expect(response_json['success']).to eq(false) - expect(response_json['errors'].first).to match(/syntax error/) - - q.sql = <<~SQL - RELEASE SAVEPOINT active_record_1 - SQL - - run_query q.id - expect(response.status).to eq(422) - expect(response_json['errors']).to_not eq([]) - expect(response_json['success']).to eq(false) - expect(response_json['errors'].first).to match(/syntax error/) - end - - it "can export data in CSV format" do - q = make_query('SELECT 23 as my_value') - post :run, params: { id: q.id, download: 1 }, format: :csv - expect(response.status).to eq(200) + get :group_reports_show, params: { group_name: group.name, id: query.id }, format: :json + expect(response.status).to eq(200) + end end end end + diff --git a/spec/guardian_spec.rb b/spec/guardian_spec.rb new file mode 100644 index 0000000..7a5ae23 --- /dev/null +++ b/spec/guardian_spec.rb @@ -0,0 +1,62 @@ +require 'rails_helper' + +describe Guardian do + + def make_query(group_ids = []) + q = DataExplorer::Query.new + q.id = Fabrication::Sequencer.sequence("query-id", 1) + q.name ="Query number #{q.id}" + q.sql = "SELECT 1" + q.group_ids = group_ids + q.save + q + end + + let(:user) { build(:user) } + let(:admin) { build(:admin) } + + describe "#user_is_a_member_of_group?" do + let(:group) { Fabricate(:group) } + + it "is true when the user is an admin" do + expect(Guardian.new(admin).user_is_a_member_of_group?(group)).to eq(true) + end + + it "is true when the user is not an admin, but is a member of the group" do + group.add(user) + + expect(Guardian.new(user).user_is_a_member_of_group?(group)).to eq(true) + end + + it "is false when the user is not an admin, and is not a member of the group" do + expect(Guardian.new(user).user_is_a_member_of_group?(group)).to eq(false) + end + end + + describe "#user_can_access_query?" do + let(:group) { Fabricate(:group) } + + it "is true if the user is an admin" do + expect(Guardian.new(admin).user_can_access_query?(group, make_query)).to eq(true) + end + + it "is true if the user is a member of the group, and query contains the group id" do + query = make_query(["#{group.id}"]) + group.add(user) + + expect(Guardian.new(user).user_can_access_query?(group, query)).to eq(true) + end + + it "is false if the query does not contain the group id" do + group.add(user) + + expect(Guardian.new(user).user_can_access_query?(group, make_query)).to eq(false) + end + + it "is false if the user is not member of the group" do + query = make_query(["#{group.id}"]) + + expect(Guardian.new(user).user_can_access_query?(group, query)).to eq(false) + end + end +end \ No newline at end of file