diff --git a/src/api-umbrella/admin-ui/app/models/admin.js b/src/api-umbrella/admin-ui/app/models/admin.js index a360b822c..c00cce891 100644 --- a/src/api-umbrella/admin-ui/app/models/admin.js +++ b/src/api-umbrella/admin-ui/app/models/admin.js @@ -13,6 +13,7 @@ class Admin extends Model.extend(Validations) { static urlRoot = '/api-umbrella/v1/admins'; static singlePayloadKey = 'admin'; static arrayPayloadKey = 'data'; + static duplicateExclude = ['username', 'email']; @attr() username; diff --git a/src/api-umbrella/admin-ui/app/models/api-scope.js b/src/api-umbrella/admin-ui/app/models/api-scope.js index 33600e901..46ddf931e 100644 --- a/src/api-umbrella/admin-ui/app/models/api-scope.js +++ b/src/api-umbrella/admin-ui/app/models/api-scope.js @@ -35,6 +35,7 @@ class ApiScope extends Model.extend(Validations) { static urlRoot = '/api-umbrella/v1/api_scopes'; static singlePayloadKey = 'api_scope'; static arrayPayloadKey = 'data'; + static duplicateExclude = ['pathPrefix']; @attr() name; diff --git a/src/api-umbrella/admin-ui/app/models/api-user.js b/src/api-umbrella/admin-ui/app/models/api-user.js index 1c0799764..60902e2f2 100644 --- a/src/api-umbrella/admin-ui/app/models/api-user.js +++ b/src/api-umbrella/admin-ui/app/models/api-user.js @@ -25,6 +25,7 @@ class ApiUser extends Model.extend(Validations) { static urlRoot = '/api-umbrella/v1/users'; static singlePayloadKey = 'user'; static arrayPayloadKey = 'data'; + static duplicateExclude = ['apiKey', 'apiKeyHidesAt', 'apiKeyPreview', 'email', 'emailVerified', 'termsAndConditions', 'sendWelcomeEmail']; @attr() apiKey; diff --git a/src/api-umbrella/admin-ui/app/models/api.js b/src/api-umbrella/admin-ui/app/models/api.js index 687b8780c..2aef9d060 100644 --- a/src/api-umbrella/admin-ui/app/models/api.js +++ b/src/api-umbrella/admin-ui/app/models/api.js @@ -111,6 +111,15 @@ class Api extends Model.extend(Validations) { if(!this.settings) { this.set('settings', this.store.createRecord('api/settings')); } + + // This can go away once we move from Ember Data's classic model layer + // to schema records/WarpDrive. We'd need to upgrade to + // ember data 5.x to do that kind of refactor. + this.subSettings.forEach((subSetting) => { + // Accessing the relationship is enough to materialize and fire init. + // eslint-disable-next-line no-unused-expressions + subSetting.settings; + }); } get exampleIncomingUrlRoot() { diff --git a/src/api-umbrella/admin-ui/app/routes/admin-groups/new.js b/src/api-umbrella/admin-ui/app/routes/admin-groups/new.js index 4f5510b91..8b914234a 100644 --- a/src/api-umbrella/admin-ui/app/routes/admin-groups/new.js +++ b/src/api-umbrella/admin-ui/app/routes/admin-groups/new.js @@ -1,13 +1,15 @@ -import { inject as service } from '@ember/service'; -import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model'; +import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route'; import Form from './form'; -export default class NewRoute extends Form { - @service store; +export default class NewRoute extends duplicableNewRoute(Form) { + duplicateModelName = 'admin-group'; - model() { - clearStoreCache(this.store); - return this.fetchModels(this.store.createRecord('admin-group')); + wrapModel(record) { + return this.fetchModels(record); + } + + modelFromResolved(resolved) { + return resolved && resolved.record; } } diff --git a/src/api-umbrella/admin-ui/app/routes/admins/new.js b/src/api-umbrella/admin-ui/app/routes/admins/new.js index d5eecfac7..9af38ead1 100644 --- a/src/api-umbrella/admin-ui/app/routes/admins/new.js +++ b/src/api-umbrella/admin-ui/app/routes/admins/new.js @@ -1,13 +1,19 @@ -import { inject as service } from '@ember/service'; -import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model'; +import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route'; import Form from './form'; -export default class NewRoute extends Form { - @service store; +export default class NewRoute extends duplicableNewRoute(Form) { + duplicateModelName = 'admin'; - model() { - clearStoreCache(this.store); - return this.fetchModels(this.store.createRecord('admin', { sendInviteEmail: true })); + newRecordAttrs() { + return { sendInviteEmail: true }; + } + + wrapModel(record) { + return this.fetchModels(record); + } + + modelFromResolved(resolved) { + return resolved && resolved.record; } } diff --git a/src/api-umbrella/admin-ui/app/routes/api-scopes/new.js b/src/api-umbrella/admin-ui/app/routes/api-scopes/new.js index eb20f0a49..0935aac9d 100644 --- a/src/api-umbrella/admin-ui/app/routes/api-scopes/new.js +++ b/src/api-umbrella/admin-ui/app/routes/api-scopes/new.js @@ -1,13 +1,7 @@ -import { inject as service } from '@ember/service'; -import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model'; +import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route'; import Form from './form'; -export default class NewRoute extends Form { - @service store; - - model() { - clearStoreCache(this.store); - return this.store.createRecord('api-scope'); - } +export default class NewRoute extends duplicableNewRoute(Form) { + duplicateModelName = 'api-scope'; } diff --git a/src/api-umbrella/admin-ui/app/routes/api-users/new.js b/src/api-umbrella/admin-ui/app/routes/api-users/new.js index a29a30dab..8a3381e5e 100644 --- a/src/api-umbrella/admin-ui/app/routes/api-users/new.js +++ b/src/api-umbrella/admin-ui/app/routes/api-users/new.js @@ -1,13 +1,15 @@ -import { inject as service } from '@ember/service'; -import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model'; +import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route'; import Form from './form'; -export default class NewRoute extends Form { - @service store; +export default class NewRoute extends duplicableNewRoute(Form) { + duplicateModelName = 'api-user'; - model() { - clearStoreCache(this.store); - return this.fetchModels(this.store.createRecord('api-user')); + wrapModel(record) { + return this.fetchModels(record); + } + + modelFromResolved(resolved) { + return resolved && resolved.record; } } diff --git a/src/api-umbrella/admin-ui/app/routes/apis/new.js b/src/api-umbrella/admin-ui/app/routes/apis/new.js index 895140f6d..c4fb100c9 100644 --- a/src/api-umbrella/admin-ui/app/routes/apis/new.js +++ b/src/api-umbrella/admin-ui/app/routes/apis/new.js @@ -1,15 +1,19 @@ -import { inject as service } from '@ember/service'; -import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model'; +import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route'; import Form from './form'; -export default class NewRoute extends Form { - @service store; +export default class NewRoute extends duplicableNewRoute(Form) { + duplicateModelName = 'api'; - model() { - clearStoreCache(this.store); - return this.fetchModels(this.store.createRecord('api', { - frontendHost: location.hostname, - })); + newRecordAttrs() { + return { frontendHost: location.hostname }; + } + + wrapModel(record) { + return this.fetchModels(record); + } + + modelFromResolved(resolved) { + return resolved && resolved.record; } } diff --git a/src/api-umbrella/admin-ui/app/routes/website-backends/new.js b/src/api-umbrella/admin-ui/app/routes/website-backends/new.js index 64f1bc44f..5a4438ef9 100644 --- a/src/api-umbrella/admin-ui/app/routes/website-backends/new.js +++ b/src/api-umbrella/admin-ui/app/routes/website-backends/new.js @@ -1,15 +1,11 @@ -import { inject as service } from '@ember/service'; -import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model'; +import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route'; import Form from './form'; -export default class NewRoute extends Form { - @service store; +export default class NewRoute extends duplicableNewRoute(Form) { + duplicateModelName = 'website-backend'; - model() { - clearStoreCache(this.store); - return this.store.createRecord('website-backend', { - serverPort: 80, - }); + newRecordAttrs() { + return { serverPort: 80 }; } } diff --git a/src/api-umbrella/admin-ui/app/services/duplicate-record.js b/src/api-umbrella/admin-ui/app/services/duplicate-record.js new file mode 100644 index 000000000..f605c1abb --- /dev/null +++ b/src/api-umbrella/admin-ui/app/services/duplicate-record.js @@ -0,0 +1,57 @@ +import Service, { inject as service } from '@ember/service'; +import cloneDeep from 'lodash-es/cloneDeep'; + +const UNIVERSAL_EXCLUDE = ['id', 'createdAt', 'updatedAt', 'creator', 'updater']; + +export default class DuplicateRecordService extends Service { + @service store; + + async cloneFromId(modelName, sourceId) { + const source = await this.store.findRecord(modelName, sourceId, { reload: true }); + const clone = this._cloneRecord(modelName, source); + clone._duplicatedFromName = source.name || source.email || 'record'; + return clone; + } + + _cloneRecord(modelName, source) { + const modelClass = this.store.modelFor(modelName); + const exclude = new Set([ + ...UNIVERSAL_EXCLUDE, + ...(modelClass.duplicateExclude || []), + ]); + + const attrs = {}; + + modelClass.eachAttribute((name) => { + if(exclude.has(name)) { + return; + } + const value = source.get(name); + attrs[name] = this._isPlainStructure(value) ? cloneDeep(value) : value; + }); + + modelClass.eachRelationship((name, descriptor) => { + if(exclude.has(name)) { + return; + } + const related = source.get(name); + if(descriptor.kind === 'belongsTo') { + attrs[name] = related ? this._cloneRecord(descriptor.type, related) : null; + } else if(descriptor.kind === 'hasMany') { + attrs[name] = (related || []).map((child) => this._cloneRecord(descriptor.type, child)); + } + }); + + return this.store.createRecord(modelName, attrs); + } + + _isPlainStructure(value) { + if(value === null || value === undefined) { + return false; + } + if(Array.isArray(value)) { + return true; + } + return typeof value === 'object' && value.constructor === Object; + } +} diff --git a/src/api-umbrella/admin-ui/app/styles/_forms.scss b/src/api-umbrella/admin-ui/app/styles/_forms.scss index 6da2ef368..1c1d38d70 100644 --- a/src/api-umbrella/admin-ui/app/styles/_forms.scss +++ b/src/api-umbrella/admin-ui/app/styles/_forms.scss @@ -119,6 +119,10 @@ fieldset .table { .form-extra-actions { margin-top: 20px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; } .custom-control.custom-control-no-label { diff --git a/src/api-umbrella/admin-ui/app/templates/components/admin-groups/record-form.hbs b/src/api-umbrella/admin-ui/app/templates/components/admin-groups/record-form.hbs index bcca86a26..efe428f16 100644 --- a/src/api-umbrella/admin-ui/app/templates/components/admin-groups/record-form.hbs +++ b/src/api-umbrella/admin-ui/app/templates/components/admin-groups/record-form.hbs @@ -43,6 +43,7 @@ {{#if this.model.id}} {{#unless this.isDisabled}}
+ Duplicate Admin Group Delete Admin Group
{{/unless}} diff --git a/src/api-umbrella/admin-ui/app/templates/components/admins/record-form.hbs b/src/api-umbrella/admin-ui/app/templates/components/admins/record-form.hbs index 6e22815fd..d32bb0b66 100644 --- a/src/api-umbrella/admin-ui/app/templates/components/admins/record-form.hbs +++ b/src/api-umbrella/admin-ui/app/templates/components/admins/record-form.hbs @@ -87,6 +87,7 @@ {{#if this.currentAdmin.permissions.admin_manage}} {{#if this.model.id}}
+ Duplicate Admin Delete Admin
{{/if}} diff --git a/src/api-umbrella/admin-ui/app/templates/components/api-scopes/record-form.hbs b/src/api-umbrella/admin-ui/app/templates/components/api-scopes/record-form.hbs index 2d89cce44..e42fcd4c9 100644 --- a/src/api-umbrella/admin-ui/app/templates/components/api-scopes/record-form.hbs +++ b/src/api-umbrella/admin-ui/app/templates/components/api-scopes/record-form.hbs @@ -57,6 +57,7 @@ {{#if this.model.id}} {{#unless this.isDisabled}}
+ Duplicate API Scope Delete API Scope
{{/unless}} diff --git a/src/api-umbrella/admin-ui/app/templates/components/api-users/record-form.hbs b/src/api-umbrella/admin-ui/app/templates/components/api-users/record-form.hbs index 952395ef7..a5bf53f2c 100644 --- a/src/api-umbrella/admin-ui/app/templates/components/api-users/record-form.hbs +++ b/src/api-umbrella/admin-ui/app/templates/components/api-users/record-form.hbs @@ -76,5 +76,10 @@ + {{#if this.model.id}} +
+ Duplicate API User +
+ {{/if}} diff --git a/src/api-umbrella/admin-ui/app/templates/components/apis/record-form.hbs b/src/api-umbrella/admin-ui/app/templates/components/apis/record-form.hbs index 1f120a49a..29a8d1b38 100644 --- a/src/api-umbrella/admin-ui/app/templates/components/apis/record-form.hbs +++ b/src/api-umbrella/admin-ui/app/templates/components/apis/record-form.hbs @@ -151,6 +151,7 @@ {{#if this.model.id}}
+ Duplicate API Delete API
{{/if}} diff --git a/src/api-umbrella/admin-ui/app/templates/components/website-backends/record-form.hbs b/src/api-umbrella/admin-ui/app/templates/components/website-backends/record-form.hbs index 17edbbee4..4a9123f09 100644 --- a/src/api-umbrella/admin-ui/app/templates/components/website-backends/record-form.hbs +++ b/src/api-umbrella/admin-ui/app/templates/components/website-backends/record-form.hbs @@ -23,6 +23,7 @@ {{#if this.model.id}}
+ Duplicate Website Backend Delete Website Backend
{{/if}} diff --git a/src/api-umbrella/admin-ui/app/utils/duplicable-new-route.js b/src/api-umbrella/admin-ui/app/utils/duplicable-new-route.js new file mode 100644 index 000000000..6912a57d2 --- /dev/null +++ b/src/api-umbrella/admin-ui/app/utils/duplicable-new-route.js @@ -0,0 +1,64 @@ +import { inject as service } from '@ember/service'; +import { success } from '@pnotify/core'; +import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model'; + +// Returns a route class that adds support for cloning a source record +// when `duplicate_id` is present in the query string. Each new route in +// the admin UI extends `duplicableNewRoute(Form)` instead of `Form` +// directly, so this class sits between Form and the resource's NewRoute. +// +// Subclasses must declare `duplicateModelName` (the Ember Data model +// name). They may override: +// - `newRecordAttrs()` to provide createRecord defaults on the +// non-duplicate path (default: `{}`). +// - `wrapModel(record)` to wrap the resolved record in a hash via +// `this.fetchModels(record)` for resources whose form route loads +// auxiliary data alongside the record (default: identity). +// - `modelFromResolved(resolved)` to extract the record from the hash +// in `afterModel` (default: identity, matching `wrapModel: identity`). +export default function duplicableNewRoute(SuperClass) { + return class DuplicableNewRoute extends SuperClass { + @service store; + @service duplicateRecord; + + queryParams = { + duplicate_id: { refreshModel: true }, + }; + + duplicateModelName = null; + + newRecordAttrs() { + return {}; + } + + wrapModel(record) { + return record; + } + + modelFromResolved(resolved) { + return resolved; + } + + async model(params) { + let record; + if(params.duplicate_id) { + record = await this.duplicateRecord.cloneFromId(this.duplicateModelName, params.duplicate_id); + } else { + clearStoreCache(this.store); + record = this.store.createRecord(this.duplicateModelName, this.newRecordAttrs()); + } + return this.wrapModel(record); + } + + afterModel(resolved) { + const record = this.modelFromResolved(resolved); + if(record && record._duplicatedFromName) { + success({ + title: 'Duplicated', + text: `Duplicated from ${record._duplicatedFromName}`, + }); + record._duplicatedFromName = null; + } + } + }; +} diff --git a/src/api-umbrella/admin-ui/config/icons.js b/src/api-umbrella/admin-ui/config/icons.js index 6b39224e9..f65977494 100644 --- a/src/api-umbrella/admin-ui/config/icons.js +++ b/src/api-umbrella/admin-ui/config/icons.js @@ -7,6 +7,7 @@ module.exports = function() { 'calendar', 'caret-down', 'cog', + 'copy', 'lock', 'map-marker-alt', 'pencil-alt', diff --git a/test/admin_ui/test_admin_groups.rb b/test/admin_ui/test_admin_groups.rb index 9c56762c7..5cebf8910 100644 --- a/test/admin_ui/test_admin_groups.rb +++ b/test/admin_ui/test_admin_groups.rb @@ -71,4 +71,44 @@ def test_update "backend_manage", ].sort, admin_group.permission_ids.sort) end + + def test_duplicate_creates_new_record_with_copied_data + api_scope = FactoryBot.create(:api_scope, :name => "Scope For Duplicate") + source = FactoryBot.create(:admin_group, :name => "Source Group For Duplicate", :api_scopes => [api_scope]) + source_id = source.id + source_api_scope_ids = source.api_scope_ids.sort + source_permission_ids = source.permission_ids.sort + + admin_login + visit "/admin/#/admin_groups/#{source.id}/edit" + assert_field("Group Name", :with => "Source Group For Duplicate") + + find("a.duplicate-action", :text => /Duplicate Admin Group/).click + + assert_current_path %r{/admin/#/admin_groups/new\?duplicate_id=#{source.id}}, :url => true + assert_text("Duplicated from Source Group For Duplicate") + assert_field("Group Name", :with => "Source Group For Duplicate") + assert_checked_field("Scope For Duplicate", :visible => :all) + + fill_in("Group Name", :with => "Duplicate Group For Duplicate") + click_button("Save") + assert_text("Successfully saved") + + duplicate = AdminGroup.where(:name => "Duplicate Group For Duplicate").order(:created_at => :desc).first + refute_nil(duplicate, "duplicate group was created") + refute_equal(source_id, duplicate.id, "duplicate has fresh id") + assert_equal(source_api_scope_ids, duplicate.api_scope_ids.sort, "duplicate references same scopes as source") + assert_equal(source_permission_ids, duplicate.permission_ids.sort, "duplicate references same permissions as source") + + source.reload + assert_equal("Source Group For Duplicate", source.name, "source name unchanged") + assert_equal(source_api_scope_ids, source.api_scope_ids.sort, "source scopes unchanged") + end + + def test_duplicate_link_hidden_on_new_form + admin_login + visit "/admin/#/admin_groups/new" + assert_field("Group Name") + refute_selector("a.duplicate-action") + end end diff --git a/test/admin_ui/test_admins.rb b/test/admin_ui/test_admins.rb index f1d6c06f6..ffd622918 100644 --- a/test/admin_ui/test_admins.rb +++ b/test/admin_ui/test_admins.rb @@ -99,4 +99,45 @@ def test_non_admin_manager_views_own_profile assert_text(admin.authentication_token) refute_button("Save") end + + def test_duplicate_creates_new_record_with_email_cleared + group = FactoryBot.create(:admin_group, :name => "Group For Duplicate") + source = FactoryBot.create(:limited_admin, :username => "source.admin@example.com", :groups => [group]) + source_id = source.id + source_username = source.username + source_email = source.email + source_group_ids = source.group_ids.sort + + admin_login + visit "/admin/#/admins/#{source.id}/edit" + assert_field("Email", :with => "source.admin@example.com") + + find("a.duplicate-action", :text => /Duplicate Admin/).click + + assert_current_path %r{/admin/#/admins/new\?duplicate_id=#{source.id}}, :url => true + assert_text("Duplicated from") + assert_field("Email", :with => "") + + fill_in("Email", :with => "duplicate.admin@example.com") + click_button("Save") + assert_text("Successfully saved the admin") + + duplicate = Admin.where(:username => "duplicate.admin@example.com").order(:created_at => :desc).first + refute_nil(duplicate, "duplicate admin was created") + refute_equal(source_id, duplicate.id, "duplicate has fresh id") + refute_equal(source_username, duplicate.username, "duplicate has fresh username") + refute_equal(source_email, duplicate.email, "duplicate has fresh email") + assert_equal(source_group_ids, duplicate.group_ids.sort, "duplicate references same groups as source") + + source.reload + assert_equal(source_username, source.username, "source username unchanged") + assert_equal(source_email, source.email, "source email unchanged") + end + + def test_duplicate_link_hidden_on_new_form + admin_login + visit "/admin/#/admins/new" + assert_text("Email") + refute_selector("a.duplicate-action") + end end diff --git a/test/admin_ui/test_api_scopes.rb b/test/admin_ui/test_api_scopes.rb index 2800ab010..2e96363b9 100644 --- a/test/admin_ui/test_api_scopes.rb +++ b/test/admin_ui/test_api_scopes.rb @@ -49,4 +49,45 @@ def test_update assert_equal("2.example.com", api_scope.host) assert_equal("/2/", api_scope.path_prefix) end + + def test_duplicate_creates_new_record_with_path_prefix_cleared + source = FactoryBot.create(:api_scope, :name => "Source Scope For Duplicate", :host => "source.example.com", :path_prefix => "/source/") + source_id = source.id + source_host = source.host + source_path_prefix = source.path_prefix + + admin_login + visit "/admin/#/api_scopes/#{source.id}/edit" + assert_field("Name", :with => "Source Scope For Duplicate") + + find("a.duplicate-action", :text => /Duplicate API Scope/).click + + assert_current_path %r{/admin/#/api_scopes/new\?duplicate_id=#{source.id}}, :url => true + assert_text("Duplicated from Source Scope For Duplicate") + assert_field("Name", :with => "Source Scope For Duplicate") + assert_field("Host", :with => "source.example.com") + assert_field("Path Prefix", :with => "") + + fill_in("Name", :with => "Duplicate Scope For Duplicate") + fill_in("Path Prefix", :with => "/duplicate/") + click_button("Save") + assert_text("Successfully saved") + + duplicate = ApiScope.where(:name => "Duplicate Scope For Duplicate").order(:created_at => :desc).first + refute_nil(duplicate, "duplicate scope was created") + refute_equal(source_id, duplicate.id, "duplicate has fresh id") + assert_equal(source_host, duplicate.host, "host preserved") + assert_equal("/duplicate/", duplicate.path_prefix, "duplicate has new path_prefix") + + source.reload + assert_equal("Source Scope For Duplicate", source.name, "source name unchanged") + assert_equal(source_path_prefix, source.path_prefix, "source path_prefix unchanged") + end + + def test_duplicate_link_hidden_on_new_form + admin_login + visit "/admin/#/api_scopes/new" + assert_field("Name") + refute_selector("a.duplicate-action") + end end diff --git a/test/admin_ui/test_api_users.rb b/test/admin_ui/test_api_users.rb index 95e6ae4d1..6f53487c7 100644 --- a/test/admin_ui/test_api_users.rb +++ b/test/admin_ui/test_api_users.rb @@ -80,4 +80,58 @@ def test_metadata_timezone_display assert_text("Created: 2015-01-15 11:06 PM MST by ") assert_text("Last Updated: 2015-07-16 12:09 AM MDT by ") end + + def test_duplicate_creates_new_record_with_email_cleared + source = FactoryBot.create(:api_user, :first_name => "Source", :last_name => "User", :email => "source.user@example.com", :use_description => "Some description") + source_id = source.id + source_email = source.email + source_api_key = source.api_key + source_api_key_hash = source.api_key_hash + source_settings_id = source.settings&.id + source_roles = source.roles.dup if source.roles + + admin_login + visit "/admin/#/api_users/#{source.id}/edit" + assert_field("First Name", :with => "Source") + + find("a.duplicate-action", :text => /Duplicate API User/).click + + assert_current_path %r{/admin/#/api_users/new\?duplicate_id=#{source.id}}, :url => true + assert_text("Duplicated from #{source.email}") + assert_field("E-mail", :with => "") + assert_field("First Name", :with => "Source") + assert_field("Last Name", :with => "User") + + fill_in("E-mail", :with => "duplicate.user@example.com") + label_check "User agrees to the terms and conditions" + click_button("Save") + assert_text("Successfully saved") + + duplicate = ApiUser.where(:email => "duplicate.user@example.com").order(:created_at => :desc).first + refute_nil(duplicate, "duplicate user was created") + refute_equal(source_id, duplicate.id, "duplicate has fresh id") + refute_equal(source_email, duplicate.email, "duplicate has fresh email") + refute_equal(source_api_key, duplicate.api_key, "duplicate has fresh api_key") + refute_equal(source_api_key_hash, duplicate.api_key_hash, "duplicate has fresh api_key_hash") + + if source.settings + refute_nil(duplicate.settings, "duplicate has settings") + refute_equal(source_settings_id, duplicate.settings.id, "duplicate settings has fresh id") + end + + if source_roles + assert_equal(source_roles.sort, (duplicate.roles || []).sort, "duplicate roles preserved by value") + end + + source.reload + assert_equal(source_email, source.email, "source email unchanged") + assert_equal(source_api_key, source.api_key, "source api_key unchanged") + end + + def test_duplicate_link_hidden_on_new_form + admin_login + visit "/admin/#/api_users/new" + assert_field("E-mail") + refute_selector("a.duplicate-action") + end end diff --git a/test/admin_ui/test_apis.rb b/test/admin_ui/test_apis.rb index b53f92659..af3e9321d 100644 --- a/test/admin_ui/test_apis.rb +++ b/test/admin_ui/test_apis.rb @@ -432,4 +432,78 @@ def test_nested_select_menu_behavior_inside_modals assert_select("HTTP Method", :selected => "OPTIONS") end end + + def test_duplicate_creates_new_record_with_copied_data + source = FactoryBot.create(:api_backend_with_all_relationships, :name => "Source Backend Test") + source_id = source.id + source_server_ids = source.servers.map(&:id) + source_url_match_ids = source.url_matches.map(&:id) + source_settings_id = source.settings&.id + source_sub_settings_ids = source.sub_settings.map(&:id) + source_rewrites_ids = source.rewrites.map(&:id) + + admin_login + visit "/admin/#/apis/#{source.id}/edit" + assert_field("Name", :with => "Source Backend Test") + + find("a.duplicate-action", :text => /Duplicate API/).click + + assert_current_path %r{/admin/#/apis/new\?duplicate_id=#{source.id}}, :url => true + assert_text("Duplicated from Source Backend Test") + assert_field("Name", :with => "Source Backend Test") + assert_field("Frontend Host", :with => source.frontend_host) + + fill_in("Name", :with => "Duplicate Backend Test") + click_button("Save") + assert_text("Successfully saved") + + duplicate = ApiBackend.where(:name => "Duplicate Backend Test").order(:created_at => :desc).first + refute_nil(duplicate, "duplicate backend was created") + refute_equal(source_id, duplicate.id, "duplicate has a different id from source") + + assert_equal(source.servers.count, duplicate.servers.count, "duplicate has same number of servers") + duplicate.servers.each do |server| + refute_includes(source_server_ids, server.id, "duplicate server has fresh id") + assert_equal(duplicate.id, server.api_backend_id, "duplicate server FK points to duplicate") + end + + assert_equal(source.url_matches.count, duplicate.url_matches.count, "duplicate has same number of url_matches") + duplicate.url_matches.each do |um| + refute_includes(source_url_match_ids, um.id, "duplicate url_match has fresh id") + assert_equal(duplicate.id, um.api_backend_id, "duplicate url_match FK points to duplicate") + end + + if source.settings + refute_nil(duplicate.settings, "duplicate has settings") + refute_equal(source_settings_id, duplicate.settings.id, "duplicate settings has fresh id") + assert_equal(duplicate.id, duplicate.settings.api_backend_id, "duplicate settings FK points to duplicate") + end + + assert_equal(source.sub_settings.count, duplicate.sub_settings.count, "duplicate has same number of sub_settings") + duplicate.sub_settings.each do |ss| + refute_includes(source_sub_settings_ids, ss.id, "duplicate sub-setting has fresh id") + assert_equal(duplicate.id, ss.api_backend_id, "duplicate sub-setting FK points to duplicate") + if ss.settings + refute_nil(ss.settings, "duplicate sub-setting has nested settings") + refute_includes([source_settings_id], ss.settings.id, "duplicate nested settings has fresh id") + end + end + + assert_equal(source.rewrites.count, duplicate.rewrites.count, "duplicate has same number of rewrites") + duplicate.rewrites.each do |rw| + refute_includes(source_rewrites_ids, rw.id, "duplicate rewrite has fresh id") + assert_equal(duplicate.id, rw.api_backend_id, "duplicate rewrite FK points to duplicate") + end + + source.reload + assert_equal("Source Backend Test", source.name, "source name unchanged") + assert_equal(source_server_ids.sort, source.servers.map(&:id).sort, "source servers unchanged") + end + + def test_duplicate_link_hidden_on_new_form + admin_login + visit "/admin/#/apis/new" + assert_field("Name") + refute_selector("a.duplicate-action") + end end diff --git a/test/admin_ui/test_website_backends.rb b/test/admin_ui/test_website_backends.rb new file mode 100644 index 000000000..902293b1d --- /dev/null +++ b/test/admin_ui/test_website_backends.rb @@ -0,0 +1,52 @@ +require_relative "../test_helper" + +class Test::AdminUi::TestWebsiteBackends < Minitest::Capybara::Test + include Capybara::Screenshot::MiniTestPlugin + include ApiUmbrellaTestHelpers::AdminAuth + include ApiUmbrellaTestHelpers::Setup + + def setup + super + setup_server + end + + def test_duplicate_creates_new_record_with_copied_data + source = FactoryBot.create(:website_backend, :frontend_host => "source.example.com", :server_host => "backend.example.com", :server_port => 8080) + source_id = source.id + source_frontend_host = source.frontend_host + source_server_host = source.server_host + source_server_port = source.server_port + + admin_login + visit "/admin/#/website_backends/#{source.id}/edit" + assert_field("Frontend Host", :with => "source.example.com") + + find("a.duplicate-action", :text => /Duplicate Website Backend/).click + + assert_current_path %r{/admin/#/website_backends/new\?duplicate_id=#{source.id}}, :url => true + assert_text("Duplicated from") + assert_field("Frontend Host", :with => "source.example.com") + assert_field("Backend Server", :with => "backend.example.com") + assert_field("Backend Port", :with => "8080") + + fill_in("Frontend Host", :with => "duplicate.example.com") + click_button("Save") + assert_text("Successfully saved") + + duplicate = WebsiteBackend.where(:frontend_host => "duplicate.example.com").order(:created_at => :desc).first + refute_nil(duplicate, "duplicate website-backend was created") + refute_equal(source_id, duplicate.id, "duplicate has fresh id") + assert_equal(source_server_host, duplicate.server_host, "server_host preserved") + assert_equal(source_server_port, duplicate.server_port, "server_port preserved") + + source.reload + assert_equal(source_frontend_host, source.frontend_host, "source frontend_host unchanged") + end + + def test_duplicate_link_hidden_on_new_form + admin_login + visit "/admin/#/website_backends/new" + assert_field("Frontend Host") + refute_selector("a.duplicate-action") + end +end