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}}
{{/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}}
{{/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}}
{{/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}}
+
+ {{/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}}
{{/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}}
{{/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