Commit 4fd19599 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 45b76743 a32cb47b
63abf93ad828f7a7924f3e0bb1fea8ea43d7c6af
c35fba1c073deed91d1a1f9f11dd668856841d80
import $ from 'jquery';
export default class AjaxLoadingSpinner {
static init() {
const $elements = $('.js-ajax-loading-spinner');
$elements.on('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
}
static ajaxBeforeSend(e) {
const button = e.target;
const newButton = document.createElement('button');
newButton.classList.add('btn', 'btn-default', 'disabled', 'gl-button');
newButton.setAttribute('disabled', 'disabled');
const spinner = document.createElement('span');
spinner.classList.add('align-text-bottom', 'gl-spinner', 'gl-spinner-sm', 'gl-spinner-orange');
newButton.appendChild(spinner);
button.classList.add('hidden');
button.parentNode.insertBefore(newButton, button.nextSibling);
$(button).one('ajax:error', () => {
newButton.remove();
button.classList.remove('hidden');
});
$(button).one('ajax:success', () => {
$(button).off('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
});
}
}
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
import BranchSortDropdown from '~/branches/branch_sort_dropdown';
import initDiverganceGraph from '~/branches/divergence_graph';
import initDeleteBranchButton from '~/branches/init_delete_branch_button';
import initDeleteBranchModal from '~/branches/init_delete_branch_modal';
AjaxLoadingSpinner.init();
const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
'.js-branch-list',
).dataset;
......
<script>
import { uniqueId } from 'lodash';
import { GlButton, GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
import { s__ } from '~/locale';
const VALIDATION_STATE = {
NO_VALIDATION: null,
INVALID: false,
VALID: true,
};
export const i18n = {
addStepButtonLabel: s__('PipelineWizardListWidget|add another step'),
removeStepButtonLabel: s__('PipelineWizardListWidget|remove step'),
invalidFeedback: s__('PipelineWizardInputValidation|This value is not valid'),
errors: {
needsAnyValueError: s__('PipelineWizardInputValidation|At least one entry is required'),
},
};
export default {
i18n,
name: 'ListWidget',
components: {
GlButton,
GlFormGroup,
GlFormInputGroup,
},
props: {
label: {
type: String,
required: true,
},
description: {
type: String,
required: false,
default: null,
},
placeholder: {
type: String,
required: false,
default: null,
},
default: {
type: Array,
required: false,
default: null,
},
invalidFeedback: {
type: String,
required: false,
default: i18n.invalidFeedback,
},
id: {
type: String,
required: false,
default: () => uniqueId('listWidget-'),
},
pattern: {
type: String,
required: false,
default: null,
},
required: {
type: Boolean,
required: false,
default: false,
},
validate: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
touched: false,
value: this.default ? this.default.map(this.getAsValueEntry) : [this.getAsValueEntry(null)],
};
},
computed: {
sanitizedValue() {
// Filter out empty steps
return this.value.filter(({ value }) => Boolean(value)).map(({ value }) => value) || [];
},
hasAnyValue() {
return this.value.some(({ value }) => Boolean(value));
},
needsAnyValue() {
return this.required && !this.value.some(({ value }) => Boolean(value));
},
inputFieldStates() {
return this.value.map(this.getValidationStateForValue);
},
inputGroupState() {
return this.showValidationState
? this.inputFieldStates.every((v) => v !== VALIDATION_STATE.INVALID)
: VALIDATION_STATE.NO_VALIDATION;
},
showValidationState() {
return this.touched || this.validate;
},
feedback() {
return this.needsAnyValue
? this.$options.i18n.errors.needsAnyValueError
: this.invalidFeedback;
},
},
async created() {
if (this.default) {
// emit an updated default value
await this.$nextTick();
this.$emit('input', this.sanitizedValue);
}
},
methods: {
addInputField() {
this.value.push(this.getAsValueEntry(null));
},
getAsValueEntry(value) {
return {
id: uniqueId('listValue-'),
value,
};
},
getValidationStateForValue({ value }, fieldIndex) {
// If we require a value to be set, mark the first
// field as invalid, but not all of them.
if (this.needsAnyValue && fieldIndex === 0) return VALIDATION_STATE.INVALID;
if (!value) return VALIDATION_STATE.NO_VALIDATION;
return this.passesPatternValidation(value)
? VALIDATION_STATE.VALID
: VALIDATION_STATE.INVALID;
},
passesPatternValidation(v) {
return !this.pattern || new RegExp(this.pattern).test(v);
},
async onValueUpdate() {
await this.$nextTick();
this.$emit('input', this.sanitizedValue);
},
onTouch() {
this.touched = true;
},
removeValue(index) {
this.value.splice(index, 1);
this.onValueUpdate();
},
},
};
</script>
<template>
<div class="gl-mb-6">
<gl-form-group
:invalid-feedback="feedback"
:label="label"
:label-description="description"
:state="inputGroupState"
class="gl-mb-2"
>
<gl-form-input-group
v-for="(item, i) in value"
:key="item.id"
v-model.trim="value[i].value"
:placeholder="i === 0 ? placeholder : undefined"
:state="inputFieldStates[i]"
class="gl-mb-2"
type="text"
@blur="onTouch"
@input="onValueUpdate"
>
<template v-if="value.length > 1" #append>
<gl-button
:aria-label="$options.i18n.removeStepButtonLabel"
category="secondary"
data-testid="remove-step-button"
icon="remove"
@click="removeValue"
/>
</template>
</gl-form-input-group>
</gl-form-group>
<gl-button
category="tertiary"
data-testid="add-step-button"
icon="plus"
size="small"
variant="confirm"
@click="addInputField"
>
{{ $options.i18n.addStepButtonLabel }}
</gl-button>
</div>
</template>
......@@ -31,15 +31,21 @@ class Namespace
# ActiveRecord. https://github.com/rails/rails/issues/13496
# Ideally it would be:
# `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')`
sql = """
UPDATE namespaces
SET traversal_ids = cte.traversal_ids
FROM (#{recursive_traversal_ids}) as cte
WHERE namespaces.id = cte.id
AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
"""
sql = <<-SQL
UPDATE namespaces
SET traversal_ids = cte.traversal_ids
FROM (#{recursive_traversal_ids}) as cte
WHERE namespaces.id = cte.id
AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
SQL
Namespace.transaction do
@root.lock!
if Feature.enabled?(:for_no_key_update_lock, default_enabled: :yaml)
@root.lock!("FOR NO KEY UPDATE")
else
@root.lock!
end
Namespace.connection.exec_query(sql)
end
rescue ActiveRecord::Deadlocked
......
---
name: for_no_key_update_lock
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81239
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353619
milestone: '14.9'
type: development
group: group::workspaces
default_enabled: false
......@@ -121,8 +121,8 @@ Then create policies that allow you to read these secrets (one for each secret):
$ vault policy write myproject-staging - <<EOF
# Policy name: myproject-staging
#
# Read-only permission on 'secret/data/myproject/staging/*' path
path "secret/data/myproject/staging/*" {
# Read-only permission on 'secret/myproject/staging/*' path
path "secret/myproject/staging/*" {
capabilities = [ "read" ]
}
EOF
......@@ -131,8 +131,8 @@ Success! Uploaded policy: myproject-staging
$ vault policy write myproject-production - <<EOF
# Policy name: myproject-production
#
# Read-only permission on 'secret/data/myproject/production/*' path
path "secret/data/myproject/production/*" {
# Read-only permission on 'secret/myproject/production/*' path
path "secret/myproject/production/*" {
capabilities = [ "read" ]
}
EOF
......
......@@ -3,10 +3,17 @@ import { GlFriendlyWrap, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui
import { REPORT_TYPES } from 'ee/security_dashboard/store/constants';
import FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.vue';
import GenericReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue';
import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
SUPPORTING_MESSAGE_TYPES,
VULNERABILITY_TRAINING_HEADING,
} from 'ee/vulnerabilities/constants';
import {
convertObjectPropsToCamelCase,
convertArrayOfObjectsToCamelCase,
} from '~/lib/utils/common_utils';
import { s__, sprintf } from '~/locale';
import CodeBlock from '~/vue_shared/components/code_block.vue';
import VulnerabilityTraining from 'ee/vulnerabilities/components/vulnerability_training.vue';
import getFileLocation from '../store/utils/get_file_location';
import { bodyWithFallBack } from './helpers';
import SeverityBadge from './severity_badge.vue';
......@@ -23,11 +30,17 @@ export default {
GlLink,
GlBadge,
FalsePositiveAlert,
VulnerabilityTraining,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
props: { vulnerability: { type: Object, required: true } },
data() {
return {
showTraining: false,
};
},
computed: {
url() {
return this.vulnerability.request?.url || getFileLocation(this.vulnLocation);
......@@ -141,6 +154,9 @@ export default {
hasRecordedResponse() {
return Boolean(this.constructedRecordedResponse);
},
camelCaseFormattedIdentifiers() {
return convertArrayOfObjectsToCamelCase(this.identifiers);
},
},
methods: {
getHeadersAsCodeBlockLines(headers) {
......@@ -175,6 +191,12 @@ export default {
? [`${method} ${url}\n`, headerLines, '\n\n', bodyWithFallBack(body)].join('')
: '';
},
handleShowTraining(showVulnerabilityTraining) {
this.showTraining = showVulnerabilityTraining;
},
},
i18n: {
VULNERABILITY_TRAINING_HEADING,
},
};
</script>
......@@ -309,5 +331,13 @@ export default {
class="gl-mt-4"
:details="vulnerability.details"
/>
<div v-if="identifiers" v-show="showTraining">
<vulnerability-detail :label="$options.i18n.VULNERABILITY_TRAINING_HEADING.title">
<vulnerability-training
:identifiers="camelCaseFormattedIdentifiers"
@show-vulnerability-training="handleShowTraining"
/>
</vulnerability-detail>
</div>
</div>
</template>
......@@ -65,10 +65,10 @@ export default {
},
computed: {
showVulnerabilityTraining() {
return (
return Boolean(
this.glFeatures.secureVulnerabilityTraining &&
this.enabledSecurityTrainingProviders?.length &&
this.identifiers?.length
this.enabledSecurityTrainingProviders?.length &&
this.identifiers?.length,
);
},
enabledSecurityTrainingProviders() {
......@@ -84,6 +84,12 @@ export default {
},
},
watch: {
showVulnerabilityTraining: {
immediate: true,
handler(showVulnerabilityTraining) {
this.$emit('show-vulnerability-training', showVulnerabilityTraining);
},
},
supportedIdentifier: {
immediate: true,
handler(supportedIdentifier) {
......
......@@ -11,6 +11,7 @@ module EE
before_action do
push_frontend_feature_flag(:pipeline_security_dashboard_graphql, project, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:graphql_code_quality_full_report, project, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml)
end
feature_category :license_compliance, [:licenses]
......
......@@ -200,5 +200,17 @@ key2: value2
<!---->
<!---->
<div
style="display: none;"
>
<vulnerability-detail-stub
label="Training"
>
<vulnerability-training-stub
identifiers="[object Object],[object Object]"
/>
</vulnerability-detail-stub>
</div>
</div>
`;
......@@ -9,7 +9,7 @@ import GenericReportSection from 'ee/vulnerabilities/components/generic_report/r
import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'helpers/test_constants';
import VulnerabilityTraining from 'ee/vulnerabilities/components/vulnerability_training.vue';
import { mockFindings } from '../mock_data';
function makeVulnerability(changes = {}) {
......@@ -23,6 +23,9 @@ describe('VulnerabilityDetails component', () => {
wrapper = mountExtended(VulnerabilityDetails, {
propsData: { vulnerability },
provide,
stubs: {
VulnerabilityTraining: true,
},
});
};
......@@ -135,6 +138,17 @@ describe('VulnerabilityDetails component', () => {
);
});
it('renders vulnerability training', () => {
const identifiers = [{ externalType: 'cwe' }, { externalType: 'cve' }];
const vulnerability = makeVulnerability({ identifiers });
componentFactory(vulnerability);
expect(wrapper.findComponent(VulnerabilityTraining).props()).toMatchObject({
identifiers,
});
});
describe('does not render XSS links', () => {
// eslint-disable-next-line no-script-url
const badUrl = 'javascript:alert("")';
......
......@@ -86,6 +86,9 @@ describe('Grouped security reports app', () => {
const createWrapper = (propsData, options, provide) => {
wrapper = mount(GroupedSecurityReportsApp, {
propsData,
stubs: {
VulnerabilityTraining: true,
},
mocks: {
$apollo: {
queries: {
......
import Vue from 'vue';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import MockAdapter from 'axios-mock-adapter';
import * as Sentry from '@sentry/browser';
......@@ -110,6 +110,17 @@ describe('VulnerabilityTraining component', () => {
expect(wrapper.html()).toBeFalsy();
});
it('watches showVulnerabilityTraining and emits change', async () => {
createApolloProvider();
createComponent();
await waitForQueryToBeLoaded();
await nextTick();
// Note: the event emits twice - the second time is when the query is loaded
expect(wrapper.emitted('show-vulnerability-training')).toEqual([[false], [true]]);
});
});
describe('with title slot', () => {
......
......@@ -26813,12 +26813,21 @@ msgstr ""
msgid "PipelineWizardDefaultCommitMessage|Update %{filename}"
msgstr ""
msgid "PipelineWizardInputValidation|At least one entry is required"
msgstr ""
msgid "PipelineWizardInputValidation|This field is required"
msgstr ""
msgid "PipelineWizardInputValidation|This value is not valid"
msgstr ""
msgid "PipelineWizardListWidget|add another step"
msgstr ""
msgid "PipelineWizardListWidget|remove step"
msgstr ""
msgid "PipelineWizard|Commit"
msgstr ""
......
import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
describe('Ajax Loading Spinner', () => {
let ajaxLoadingSpinnerElement;
let fauxEvent;
beforeEach(() => {
document.body.innerHTML = `
<div>
<a class="js-ajax-loading-spinner"
data-remote
href="http://goesnowhere.nothing/whereami">
Remove me
</a></div>`;
AjaxLoadingSpinner.init();
ajaxLoadingSpinnerElement = document.querySelector('.js-ajax-loading-spinner');
fauxEvent = { target: ajaxLoadingSpinnerElement };
});
afterEach(() => {
document.body.innerHTML = '';
});
it('`ajaxBeforeSend` event handler sets current icon to spinner and disables link', () => {
expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).toBeNull();
expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(false);
AjaxLoadingSpinner.ajaxBeforeSend(fauxEvent);
expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).not.toBeNull();
expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(true);
});
});
import { GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
import { nextTick } from 'vue';
import ListWidget from '~/pipeline_wizard/components/widgets/list.vue';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
describe('Pipeline Wizard - List Widget', () => {
const defaultProps = {
label: 'This label',
description: 'some description',
placeholder: 'some placeholder',
pattern: '^[a-z]+$',
invalidFeedback: 'some feedback',
};
let wrapper;
let addStepBtn;
const findGlFormGroup = () => wrapper.findComponent(GlFormGroup);
const findGlFormGroupInvalidFeedback = () => findGlFormGroup().find('.invalid-feedback').text();
const findFirstGlFormInputGroup = () => wrapper.findComponent(GlFormInputGroup);
const findAllGlFormInputGroups = () => wrapper.findAllComponents(GlFormInputGroup);
const findGlFormInputGroupByIndex = (index) => findAllGlFormInputGroups().at(index);
const setValueOnInputField = (value, atIndex = 0) => {
return findGlFormInputGroupByIndex(atIndex).vm.$emit('input', value);
};
const findAddStepButton = () => wrapper.findByTestId('add-step-button');
const addStep = () => findAddStepButton().vm.$emit('click');
const createComponent = (props = {}, mountFn = shallowMountExtended) => {
wrapper = mountFn(ListWidget, {
propsData: {
...defaultProps,
...props,
},
});
addStepBtn = findAddStepButton();
};
describe('component setup and interface', () => {
afterEach(() => {
wrapper.destroy();
});
it('prints the label inside the legend', () => {
createComponent();
expect(findGlFormGroup().attributes('label')).toBe(defaultProps.label);
});
it('prints the description inside the legend', () => {
createComponent();
expect(findGlFormGroup().attributes('labeldescription')).toBe(defaultProps.description);
});
it('sets the input field type attribute to "text"', async () => {
createComponent();
expect(findFirstGlFormInputGroup().attributes('type')).toBe('text');
});
it('passes the placeholder to the first input field', () => {
createComponent();
expect(findFirstGlFormInputGroup().attributes('placeholder')).toBe(defaultProps.placeholder);
});
it('shows a delete button on all fields if there are more than one', async () => {
createComponent({}, mountExtended);
await addStep();
await addStep();
const inputGroups = findAllGlFormInputGroups().wrappers;
expect(inputGroups.length).toBe(3);
inputGroups.forEach((inputGroup) => {
const button = inputGroup.find('[data-testid="remove-step-button"]');
expect(button.find('[data-testid="remove-icon"]').exists()).toBe(true);
expect(button.attributes('aria-label')).toBe('remove step');
});
});
it('null values do not cause an input event', async () => {
createComponent();
await addStep();
expect(wrapper.emitted('input')).toBe(undefined);
});
it('hides the delete button if there is only one', () => {
createComponent({}, mountExtended);
const inputGroups = findAllGlFormInputGroups().wrappers;
expect(inputGroups.length).toBe(1);
expect(wrapper.findByTestId('remove-step-button').exists()).toBe(false);
});
it('shows an "add step" button', () => {
createComponent();
expect(addStepBtn.attributes('icon')).toBe('plus');
expect(addStepBtn.text()).toBe('add another step');
});
it('the "add step" button increases the number of input fields', async () => {
createComponent();
expect(findAllGlFormInputGroups().wrappers.length).toBe(1);
await addStep();
expect(findAllGlFormInputGroups().wrappers.length).toBe(2);
});
it('does not pass the placeholder on subsequent input fields', async () => {
createComponent();
await addStep();
await addStep();
const nullOrUndefined = [null, undefined];
expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(1).attributes('placeholder'));
expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(2).attributes('placeholder'));
});
it('emits an update event on input', async () => {
createComponent();
const localValue = 'somevalue';
await setValueOnInputField(localValue);
await nextTick();
expect(wrapper.emitted('input')).toEqual([[[localValue]]]);
});
it('only emits non-null values', async () => {
createComponent();
await addStep();
await addStep();
await setValueOnInputField('abc', 1);
await nextTick();
const events = wrapper.emitted('input');
expect(events.length).toBe(1);
expect(events[0]).toEqual([['abc']]);
});
});
describe('form validation', () => {
afterEach(() => {
wrapper.destroy();
});
it('does not show validation state when untouched', async () => {
createComponent({}, mountExtended);
expect(findGlFormGroup().classes()).not.toContain('is-valid');
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
});
it('shows invalid state on blur', async () => {
createComponent({}, mountExtended);
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
const input = findFirstGlFormInputGroup().find('input');
await input.setValue('invalid99');
await input.trigger('blur');
expect(input.classes()).toContain('is-invalid');
expect(findGlFormGroup().classes()).toContain('is-invalid');
});
it('shows invalid state when toggling `validate` prop', async () => {
createComponent({ required: true, validate: false }, mountExtended);
await setValueOnInputField(null);
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
await wrapper.setProps({ validate: true });
expect(findGlFormGroup().classes()).toContain('is-invalid');
});
it.each`
scenario | required | values | inputFieldClasses | inputGroupClass | feedback
${'shows invalid if all inputs are empty'} | ${true} | ${[null, null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${'At least one entry is required'}
${'is valid if at least one field has a valid entry'} | ${true} | ${[null, 'abc']} | ${[null, 'is-valid']} | ${'is-valid'} | ${expect.anything()}
${'is invalid if one field has an invalid entry'} | ${true} | ${['abc', '99']} | ${['is-valid', 'is-invalid']} | ${'is-invalid'} | ${defaultProps.invalidFeedback}
${'is not invalid if its not required but all values are null'} | ${false} | ${[null, null]} | ${[null, null]} | ${'is-valid'} | ${expect.anything()}
${'is invalid if pattern does not match even if its not required'} | ${false} | ${['99', null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${defaultProps.invalidFeedback}
`('$scenario', async ({ required, values, inputFieldClasses, inputGroupClass, feedback }) => {
createComponent({ required, validate: true }, mountExtended);
await Promise.all(
values.map(async (value, i) => {
if (i > 0) {
await addStep();
}
await setValueOnInputField(value, i);
}),
);
await nextTick();
inputFieldClasses.forEach((expected, i) => {
const inputWrapper = findGlFormInputGroupByIndex(i).find('input');
if (expected === null) {
expect(inputWrapper.classes()).not.toContain('is-valid');
expect(inputWrapper.classes()).not.toContain('is-invalid');
} else {
expect(inputWrapper.classes()).toContain(expected);
}
});
expect(findGlFormGroup().classes()).toContain(inputGroupClass);
expect(findGlFormGroupInvalidFeedback()).toEqual(feedback);
});
});
});
......@@ -385,23 +385,43 @@ RSpec.describe Group do
end
end
before do
subject
reload_models(old_parent, new_parent, group)
end
context 'within the same hierarchy' do
let!(:root) { create(:group).reload }
let!(:old_parent) { create(:group, parent: root) }
let!(:new_parent) { create(:group, parent: root) }
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id]
context 'with FOR UPDATE lock' do
before do
stub_feature_flags(for_no_key_update_lock: false)
subject
reload_models(old_parent, new_parent, group)
end
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id]
end
it_behaves_like 'hierarchy with traversal_ids'
it_behaves_like 'locked row', 'FOR UPDATE' do
let(:row) { root }
end
end
it_behaves_like 'hierarchy with traversal_ids'
it_behaves_like 'locked row' do
let(:row) { root }
context 'with FOR NO KEY UPDATE lock' do
before do
stub_feature_flags(for_no_key_update_lock: true)
subject
reload_models(old_parent, new_parent, group)
end
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id]
end
it_behaves_like 'hierarchy with traversal_ids'
it_behaves_like 'locked row', 'FOR NO KEY UPDATE' do
let(:row) { root }
end
end
end
......@@ -410,6 +430,11 @@ RSpec.describe Group do
let!(:new_parent) { create(:group) }
let!(:group) { create(:group, parent: old_parent) }
before do
subject
reload_models(old_parent, new_parent, group)
end
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [new_parent.id, group.id]
end
......@@ -435,6 +460,11 @@ RSpec.describe Group do
let!(:old_parent) { nil }
let!(:new_parent) { create(:group) }
before do
subject
reload_models(old_parent, new_parent, group)
end
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [new_parent.id, group.id]
end
......@@ -452,6 +482,11 @@ RSpec.describe Group do
let!(:old_parent) { create(:group) }
let!(:new_parent) { nil }
before do
subject
reload_models(old_parent, new_parent, group)
end
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [group.id]
end
......
......@@ -68,11 +68,24 @@ RSpec.describe Namespace::TraversalHierarchy, type: :model do
end
end
it_behaves_like 'locked row' do
it_behaves_like 'locked row', 'FOR UPDATE' do
let(:recorded_queries) { ActiveRecord::QueryRecorder.new }
let(:row) { root }
before do
stub_feature_flags(for_no_key_update_lock: false)
recorded_queries.record { subject }
end
end
it_behaves_like 'locked row', 'FOR NO KEY UPDATE' do
let(:recorded_queries) { ActiveRecord::QueryRecorder.new }
let(:row) { root }
before do
stub_feature_flags(for_no_key_update_lock: true)
recorded_queries.record { subject }
end
end
......
......@@ -4,10 +4,10 @@
# Ensure a transaction also occurred.
# Be careful! This form of spec is not foolproof, but better than nothing.
RSpec.shared_examples 'locked row' do
RSpec.shared_examples 'locked row' do |lock_type|
it "has locked row" do
table_name = row.class.table_name
ids_regex = /SELECT.*FROM.*#{table_name}.*"#{table_name}"."id" = #{row.id}.+FOR UPDATE/m
ids_regex = /SELECT.*FROM.*#{table_name}.*"#{table_name}"."id" = #{row.id}.+#{lock_type}/m
expect(recorded_queries.log).to include a_string_matching 'SAVEPOINT'
expect(recorded_queries.log).to include a_string_matching ids_regex
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment